diff --git a/CLAUDE.md b/CLAUDE.md
index 6ebe2a6..7425f81 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,7 +1,7 @@
# PedsHub — AI Synopsis for Codebase Work
## What this is
-PedsHub is a pediatric medical learning platform. Admins upload PREP exam PDFs, AI extracts MCQ questions (or generates flashcards), and users study them with an AI tutor. Everything runs in Docker.
+PedsHub is a pediatric medical learning platform with an integrated LMS. Admins upload PREP exam PDFs, AI extracts MCQ questions (or generates flashcards), and users study them with an AI tutor. Any user can create courses, quizzes, and questions. Everything runs in Docker.
## Critical rules
- **Never restart services while a Celery task is running** — check `docker compose logs celery --tail=5` first
@@ -26,6 +26,18 @@ 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)
+
+## Course/LMS system
+Any user can create courses (not just moderators). Course structure: Course → Modules → Lessons.
+- **Lesson types**: text (markdown), video (Vimeo/YouTube/local), document, quiz (from question bank), live_session (BBB/Zoom/Meet)
+- **Enrollment**: users enroll in published courses, progress tracked per-lesson
+- **Video**: auto-detect provider from URL (vimeo.com → vimeo, youtube.com → youtube)
+- **BBB**: full API integration (create/join/end). Config: `BBB_SERVER_URL` + `BBB_SECRET` in .env
+- **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`)
```
## Key directories
@@ -55,6 +67,9 @@ frontend/src/
FlashcardsPage.jsx — Browse decks + card browser with search
FlashcardStudyPage.jsx — Flip cards, got-it/review, keyboard nav, progress
QuizPage.jsx — Take quiz (exam/study mode), timer, progress save to Redis
+ CoursesPage.jsx — Browse/enroll courses, my courses, create courses
+ CourseDetailPage.jsx — Student view: modules, lessons, video player, progress
+ CourseEditorPage.jsx — Course creator: modules, lessons, AI content, question bank browser
AdminPage.jsx — Model config, user management, settings
components/
Navbar.jsx — Auth-aware nav with jobs badge
@@ -76,6 +91,11 @@ frontend/src/
| question_tag_links | Question ↔ Tag | question_id, tag_id |
| flashcard_tag_links | Flashcard ↔ Tag | flashcard_id, tag_id |
| quiz_attempts | User quiz sessions with score | quiz_id, user_id |
+| courses | LMS courses (draft/published/archived) | user_id → users |
+| course_modules | Sections within a course | course_id → courses |
+| course_lessons | Items within a module (text/video/quiz/live) | module_id → course_modules, quiz_id → quizzes |
+| course_enrollments | User enrollment + progress tracking | course_id, user_id (unique) |
+| course_lesson_progress | Per-lesson completion status | enrollment_id, lesson_id (unique) |
## Common patterns
- **Tag filtering SQL**: `WHERE tag_id = ANY(:tag_ids) GROUP BY ... HAVING COUNT(DISTINCT tag_id) = :cnt` — AND logic across tags
diff --git a/backend/app/config.py b/backend/app/config.py
index dd3f407..fddf223 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -41,5 +41,8 @@ class Settings(BaseSettings):
TURNSTILE_SECRET_KEY: str = "" # Cloudflare Turnstile — leave blank to disable captcha
ADMIN_EMAIL: str = "" # Where contact form submissions are emailed
+ BBB_SERVER_URL: str = "" # BigBlueButton server URL (e.g. https://bbb.example.com/bigbluebutton)
+ BBB_SECRET: str = "" # BigBlueButton shared secret
+
settings = Settings()
diff --git a/backend/app/main.py b/backend/app/main.py
index a3a6800..afd9073 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.database import engine, Base, SessionLocal
-from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards
+from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
@@ -131,7 +131,7 @@ def setup_pgvector():
from sqlalchemy import text
# Import new models so create_all picks them up
from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa
- from app.models import flashcard # noqa
+ from app.models import flashcard, course # noqa
# Kill stale idle-in-transaction connections from previous killed startups.
# They hold DDL locks and cause ALTER TABLE below to hang indefinitely.
@@ -319,6 +319,78 @@ def setup_pgvector():
UNIQUE (user_id, deck_id)
)
"""))
+ # Quiz sharing
+ conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 0"))
+ # ── Course / LMS tables ──────────────────────────────────────
+ conn.execute(text("""
+ CREATE TABLE IF NOT EXISTS courses (
+ id SERIAL PRIMARY KEY,
+ title VARCHAR NOT NULL,
+ description TEXT,
+ thumbnail_path VARCHAR,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ status VARCHAR DEFAULT 'draft',
+ is_featured INTEGER DEFAULT 0,
+ requires_subscription INTEGER DEFAULT 0,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+ )
+ """))
+ conn.execute(text("""
+ CREATE TABLE IF NOT EXISTS course_modules (
+ id SERIAL PRIMARY KEY,
+ course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
+ title VARCHAR NOT NULL,
+ description TEXT,
+ position INTEGER DEFAULT 0
+ )
+ """))
+ conn.execute(text("""
+ CREATE TABLE IF NOT EXISTS course_lessons (
+ id SERIAL PRIMARY KEY,
+ module_id INTEGER NOT NULL REFERENCES course_modules(id) ON DELETE CASCADE,
+ title VARCHAR NOT NULL,
+ description TEXT,
+ position INTEGER DEFAULT 0,
+ lesson_type VARCHAR NOT NULL,
+ content_text TEXT,
+ video_url VARCHAR,
+ video_provider VARCHAR,
+ local_file_path VARCHAR,
+ quiz_id INTEGER REFERENCES quizzes(id) ON DELETE SET NULL,
+ live_session_url VARCHAR,
+ live_session_start TIMESTAMP,
+ live_session_end TIMESTAMP,
+ bbb_meeting_id VARCHAR,
+ duration_minutes INTEGER,
+ is_required INTEGER DEFAULT 1
+ )
+ """))
+ conn.execute(text("""
+ CREATE TABLE IF NOT EXISTS course_enrollments (
+ id SERIAL PRIMARY KEY,
+ course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ enrolled_at TIMESTAMP DEFAULT NOW(),
+ completed_at TIMESTAMP,
+ progress_pct FLOAT DEFAULT 0.0,
+ status VARCHAR DEFAULT 'enrolled',
+ UNIQUE (course_id, user_id)
+ )
+ """))
+ conn.execute(text("""
+ CREATE TABLE IF NOT EXISTS course_lesson_progress (
+ id SERIAL PRIMARY KEY,
+ enrollment_id INTEGER NOT NULL REFERENCES course_enrollments(id) ON DELETE CASCADE,
+ lesson_id INTEGER NOT NULL REFERENCES course_lessons(id) ON DELETE CASCADE,
+ status VARCHAR DEFAULT 'not_started',
+ started_at TIMESTAMP,
+ completed_at TIMESTAMP,
+ score FLOAT,
+ time_spent_seconds INTEGER DEFAULT 0,
+ UNIQUE (enrollment_id, lesson_id)
+ )
+ """))
conn.commit()
@@ -425,6 +497,7 @@ app.include_router(teach.router, prefix="/api/teach", tags=["teach"])
app.include_router(contact.router, prefix="/api/contact", tags=["contact"])
app.include_router(tags.router, prefix="/api/tags", tags=["tags"])
app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"])
+app.include_router(courses.router, prefix="/api/courses", tags=["courses"])
@app.get("/api/health")
diff --git a/backend/app/models/course.py b/backend/app/models/course.py
new file mode 100644
index 0000000..9c1a5f6
--- /dev/null
+++ b/backend/app/models/course.py
@@ -0,0 +1,86 @@
+from datetime import datetime
+from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Float
+from sqlalchemy.orm import relationship
+from app.database import Base
+
+
+class Course(Base):
+ __tablename__ = "courses"
+ id = Column(Integer, primary_key=True, index=True)
+ title = Column(String, nullable=False)
+ description = Column(Text, nullable=True)
+ thumbnail_path = Column(String, nullable=True)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
+ status = Column(String, default="draft") # draft, published, archived
+ is_featured = Column(Integer, default=0)
+ requires_subscription = Column(Integer, default=0) # 0=free, 1=paid (gate ready)
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+ modules = relationship("CourseModule", back_populates="course", cascade="all, delete-orphan", order_by="CourseModule.position")
+ enrollments = relationship("CourseEnrollment", back_populates="course", cascade="all, delete-orphan")
+ creator = relationship("User")
+
+
+class CourseModule(Base):
+ __tablename__ = "course_modules"
+ id = Column(Integer, primary_key=True, index=True)
+ course_id = Column(Integer, ForeignKey("courses.id", ondelete="CASCADE"), nullable=False)
+ title = Column(String, nullable=False)
+ description = Column(Text, nullable=True)
+ position = Column(Integer, default=0)
+
+ course = relationship("Course", back_populates="modules")
+ lessons = relationship("CourseLesson", back_populates="module", cascade="all, delete-orphan", order_by="CourseLesson.position")
+
+
+class CourseLesson(Base):
+ __tablename__ = "course_lessons"
+ id = Column(Integer, primary_key=True, index=True)
+ module_id = Column(Integer, ForeignKey("course_modules.id", ondelete="CASCADE"), nullable=False)
+ title = Column(String, nullable=False)
+ description = Column(Text, nullable=True)
+ position = Column(Integer, default=0)
+ lesson_type = Column(String, nullable=False) # text, video, document, quiz, live_session, scorm_package
+ content_text = Column(Text, nullable=True) # markdown for text lessons
+ video_url = Column(String, nullable=True) # Vimeo/YouTube URL
+ video_provider = Column(String, nullable=True) # vimeo, youtube, local
+ local_file_path = Column(String, nullable=True) # uploaded files
+ quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="SET NULL"), nullable=True)
+ live_session_url = Column(String, nullable=True) # BBB/Zoom/Meet join link
+ live_session_start = Column(DateTime, nullable=True)
+ live_session_end = Column(DateTime, nullable=True)
+ bbb_meeting_id = Column(String, nullable=True) # for BBB API integration
+ duration_minutes = Column(Integer, nullable=True)
+ is_required = Column(Integer, default=1) # 1=must complete to progress
+
+ module = relationship("CourseModule", back_populates="lessons")
+
+
+class CourseEnrollment(Base):
+ __tablename__ = "course_enrollments"
+ id = Column(Integer, primary_key=True, index=True)
+ course_id = Column(Integer, ForeignKey("courses.id", ondelete="CASCADE"), nullable=False)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
+ enrolled_at = Column(DateTime, default=datetime.utcnow)
+ completed_at = Column(DateTime, nullable=True)
+ progress_pct = Column(Float, default=0.0)
+ status = Column(String, default="enrolled") # enrolled, in_progress, completed
+
+ course = relationship("Course", back_populates="enrollments")
+ user = relationship("User")
+ lesson_progress = relationship("CourseLessonProgress", back_populates="enrollment", cascade="all, delete-orphan")
+
+
+class CourseLessonProgress(Base):
+ __tablename__ = "course_lesson_progress"
+ id = Column(Integer, primary_key=True, index=True)
+ enrollment_id = Column(Integer, ForeignKey("course_enrollments.id", ondelete="CASCADE"), nullable=False)
+ lesson_id = Column(Integer, ForeignKey("course_lessons.id", ondelete="CASCADE"), nullable=False)
+ status = Column(String, default="not_started") # not_started, in_progress, completed
+ started_at = Column(DateTime, nullable=True)
+ completed_at = Column(DateTime, nullable=True)
+ score = Column(Float, nullable=True) # for quiz lessons
+ time_spent_seconds = Column(Integer, default=0)
+
+ enrollment = relationship("CourseEnrollment", back_populates="lesson_progress")
diff --git a/backend/app/models/quiz.py b/backend/app/models/quiz.py
index 80446aa..461f914 100644
--- a/backend/app/models/quiz.py
+++ b/backend/app/models/quiz.py
@@ -23,6 +23,7 @@ class Quiz(Base):
created_at = Column(DateTime, default=datetime.utcnow)
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)
section = relationship("Section", back_populates="quizzes")
user = relationship("User", back_populates="quizzes")
diff --git a/backend/app/routers/courses.py b/backend/app/routers/courses.py
new file mode 100644
index 0000000..26d8743
--- /dev/null
+++ b/backend/app/routers/courses.py
@@ -0,0 +1,1089 @@
+"""Course management — CRUD, modules, lessons, enrollment, progress, AI content, BBB."""
+import hashlib
+import logging
+import os
+import uuid
+from datetime import datetime
+from urllib.parse import urlencode
+
+from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
+from pydantic import BaseModel
+from sqlalchemy import func
+from sqlalchemy.orm import Session
+
+from app.config import settings
+from app.database import get_db
+from app.models.course import (
+ Course, CourseModule, CourseLesson, CourseEnrollment, CourseLessonProgress,
+)
+from app.models.user import User
+from app.utils.auth import get_current_user
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+# ── Schemas ──────────────────────────────────────────────────────────
+
+class CourseCreate(BaseModel):
+ title: str
+ description: str | None = None
+ category: str | None = None
+ thumbnail_url: str | None = None
+ requires_subscription: int = 0
+
+
+class CourseUpdate(BaseModel):
+ title: str | None = None
+ description: str | None = None
+ category: str | None = None
+ thumbnail_url: str | None = None
+ requires_subscription: int | None = None
+
+
+class ModuleCreate(BaseModel):
+ title: str
+ description: str | None = None
+ sort_order: int | None = None
+
+
+class ModuleUpdate(BaseModel):
+ title: str | None = None
+ description: str | None = None
+ sort_order: int | None = None
+
+
+class ModuleReorder(BaseModel):
+ module_ids: list[int]
+
+
+class LessonCreate(BaseModel):
+ title: str
+ lesson_type: str = "video" # video, document, quiz, live
+ content_text: str | None = None
+ video_url: str | None = None
+ duration_minutes: int | None = None
+ is_required: int = 1
+ sort_order: int | None = None
+
+
+class LessonUpdate(BaseModel):
+ title: str | None = None
+ lesson_type: str | None = None
+ content_text: str | None = None
+ video_url: str | None = None
+ video_provider: str | None = None
+ duration_minutes: int | None = None
+ is_required: int | None = None
+ sort_order: int | None = None
+
+
+class ProgressUpdate(BaseModel):
+ status: str | None = None # not_started, in_progress, completed
+ time_spent: int | None = None # seconds
+ score: float | None = None
+
+
+class AIGenerateRequest(BaseModel):
+ prompt: str
+ action: str = "generate" # generate, refine, summarize
+ existing_content: str | None = None
+
+
+# ── Helpers ──────────────────────────────────────────────────────────
+
+def _course_owner_or_admin(course_id: int, current_user: User, db: Session) -> Course:
+ course = db.query(Course).filter(Course.id == course_id).first()
+ if not course:
+ raise HTTPException(status_code=404, detail="Course not found")
+ if course.user_id != current_user.id and not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Not your course")
+ return course
+
+
+def _detect_video_provider(url: str | None) -> str | None:
+ if not url:
+ return None
+ lower = url.lower()
+ if "vimeo.com" in lower:
+ return "vimeo"
+ if "youtube.com" in lower or "youtu.be" in lower:
+ return "youtube"
+ return "local"
+
+
+def _bbb_checksum(call_name: str, params: str, secret: str) -> str:
+ return hashlib.sha1(f"{call_name}{params}{secret}".encode()).hexdigest()
+
+
+# ── Course CRUD ──────────────────────────────────────────────────────
+
+@router.post("/")
+def create_course(
+ data: CourseCreate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Create a new course (any authenticated user). Starts as draft."""
+ course = Course(
+ title=data.title,
+ description=data.description,
+ category=data.category,
+ thumbnail_url=data.thumbnail_url,
+ requires_subscription=data.requires_subscription,
+ user_id=current_user.id,
+ status="draft",
+ )
+ db.add(course)
+ db.commit()
+ db.refresh(course)
+ return {
+ "id": course.id,
+ "title": course.title,
+ "status": course.status,
+ "created_at": course.created_at,
+ }
+
+
+@router.get("/")
+def list_courses(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """List courses. Published visible to all; draft/archived only to creator/admin."""
+ enrolled_count_sq = (
+ db.query(
+ CourseEnrollment.course_id,
+ func.count(CourseEnrollment.id).label("enrolled_count"),
+ )
+ .group_by(CourseEnrollment.course_id)
+ .subquery()
+ )
+
+ rows = (
+ db.query(
+ Course,
+ func.coalesce(enrolled_count_sq.c.enrolled_count, 0).label("enrolled_count"),
+ )
+ .outerjoin(enrolled_count_sq, Course.id == enrolled_count_sq.c.course_id)
+ .order_by(Course.created_at.desc())
+ .all()
+ )
+
+ user_cache: dict[int, str] = {}
+ result = []
+ for course, enrolled in rows:
+ # Filter: non-published courses only visible to creator or admin
+ if course.status != "published":
+ if course.user_id != current_user.id and not current_user.is_admin:
+ continue
+
+ if course.user_id not in user_cache:
+ owner = db.query(User).filter(User.id == course.user_id).first()
+ user_cache[course.user_id] = owner.name if owner else "Unknown"
+
+ result.append({
+ "id": course.id,
+ "title": course.title,
+ "description": course.description,
+ "category": course.category,
+ "status": course.status,
+ "thumbnail_url": course.thumbnail_url,
+ "requires_subscription": course.requires_subscription,
+ "user_id": course.user_id,
+ "creator_name": user_cache[course.user_id],
+ "enrolled_count": int(enrolled),
+ "created_at": course.created_at,
+ "updated_at": course.updated_at,
+ })
+
+ return result
+
+
+@router.get("/published")
+def list_published_courses(
+ limit: int = Query(20, le=100),
+ offset: int = Query(0),
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """List only published courses (for students browsing). Paginated."""
+ enrolled_count_sq = (
+ db.query(
+ CourseEnrollment.course_id,
+ func.count(CourseEnrollment.id).label("enrolled_count"),
+ )
+ .group_by(CourseEnrollment.course_id)
+ .subquery()
+ )
+
+ total = db.query(Course).filter(Course.status == "published").count()
+
+ rows = (
+ db.query(
+ Course,
+ func.coalesce(enrolled_count_sq.c.enrolled_count, 0).label("enrolled_count"),
+ )
+ .outerjoin(enrolled_count_sq, Course.id == enrolled_count_sq.c.course_id)
+ .filter(Course.status == "published")
+ .order_by(Course.created_at.desc())
+ .offset(offset)
+ .limit(limit)
+ .all()
+ )
+
+ user_cache: dict[int, str] = {}
+ courses = []
+ for course, enrolled in rows:
+ if course.user_id not in user_cache:
+ owner = db.query(User).filter(User.id == course.user_id).first()
+ user_cache[course.user_id] = owner.name if owner else "Unknown"
+ courses.append({
+ "id": course.id,
+ "title": course.title,
+ "description": course.description,
+ "category": course.category,
+ "thumbnail_url": course.thumbnail_url,
+ "requires_subscription": course.requires_subscription,
+ "user_id": course.user_id,
+ "creator_name": user_cache[course.user_id],
+ "enrolled_count": int(enrolled),
+ "created_at": course.created_at,
+ })
+
+ return {"total": total, "courses": courses}
+
+
+@router.get("/my-courses")
+def my_courses(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """List courses the current user is enrolled in, with progress."""
+ enrollments = (
+ db.query(CourseEnrollment)
+ .filter(CourseEnrollment.user_id == current_user.id)
+ .all()
+ )
+
+ result = []
+ for enrollment in enrollments:
+ course = db.query(Course).filter(Course.id == enrollment.course_id).first()
+ if not course:
+ continue
+ result.append({
+ "id": course.id,
+ "title": course.title,
+ "description": course.description,
+ "category": course.category,
+ "thumbnail_url": course.thumbnail_url,
+ "status": course.status,
+ "enrollment_id": enrollment.id,
+ "progress_pct": enrollment.progress_pct,
+ "enrolled_at": enrollment.enrolled_at,
+ "completed_at": enrollment.completed_at,
+ })
+
+ return result
+
+
+@router.get("/{course_id}")
+def get_course(
+ course_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Full course detail with modules and lessons. Includes enrollment progress if enrolled."""
+ course = db.query(Course).filter(Course.id == course_id).first()
+ if not course:
+ raise HTTPException(status_code=404, detail="Course not found")
+
+ # Non-published courses only visible to creator or admin
+ if course.status != "published":
+ if course.user_id != current_user.id and not current_user.is_admin:
+ raise HTTPException(status_code=404, detail="Course not found")
+
+ # Creator info
+ owner = db.query(User).filter(User.id == course.user_id).first()
+ creator_name = owner.name if owner else "Unknown"
+
+ # Enrolled count
+ enrolled_count = (
+ db.query(func.count(CourseEnrollment.id))
+ .filter(CourseEnrollment.course_id == course_id)
+ .scalar()
+ )
+
+ # Modules + lessons
+ modules = (
+ db.query(CourseModule)
+ .filter(CourseModule.course_id == course_id)
+ .order_by(CourseModule.sort_order, CourseModule.id)
+ .all()
+ )
+ modules_out = []
+ for mod in modules:
+ lessons = (
+ db.query(CourseLesson)
+ .filter(CourseLesson.module_id == mod.id)
+ .order_by(CourseLesson.sort_order, CourseLesson.id)
+ .all()
+ )
+ modules_out.append({
+ "id": mod.id,
+ "title": mod.title,
+ "description": mod.description,
+ "sort_order": mod.sort_order,
+ "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,
+ "file_path": les.file_path,
+ "duration_minutes": les.duration_minutes,
+ "is_required": les.is_required,
+ "sort_order": les.sort_order,
+ "bbb_meeting_id": les.bbb_meeting_id,
+ }
+ for les in lessons
+ ],
+ })
+
+ # My enrollment + progress
+ my_enrollment = None
+ enrollment = (
+ db.query(CourseEnrollment)
+ .filter(
+ CourseEnrollment.course_id == course_id,
+ CourseEnrollment.user_id == current_user.id,
+ )
+ .first()
+ )
+ if enrollment:
+ lesson_progress = (
+ db.query(CourseLessonProgress)
+ .filter(CourseLessonProgress.enrollment_id == enrollment.id)
+ .all()
+ )
+ my_enrollment = {
+ "enrollment_id": enrollment.id,
+ "progress_pct": enrollment.progress_pct,
+ "enrolled_at": enrollment.enrolled_at,
+ "completed_at": enrollment.completed_at,
+ "lesson_progress": {
+ lp.lesson_id: {
+ "status": lp.status,
+ "time_spent": lp.time_spent,
+ "score": lp.score,
+ "completed_at": lp.completed_at,
+ }
+ for lp in lesson_progress
+ },
+ }
+
+ return {
+ "id": course.id,
+ "title": course.title,
+ "description": course.description,
+ "category": course.category,
+ "status": course.status,
+ "thumbnail_url": course.thumbnail_url,
+ "requires_subscription": course.requires_subscription,
+ "user_id": course.user_id,
+ "creator_name": creator_name,
+ "enrolled_count": enrolled_count,
+ "created_at": course.created_at,
+ "updated_at": course.updated_at,
+ "modules": modules_out,
+ "my_enrollment": my_enrollment,
+ }
+
+
+@router.put("/{course_id}")
+def update_course(
+ course_id: int,
+ data: CourseUpdate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Update course metadata (creator or admin)."""
+ course = _course_owner_or_admin(course_id, current_user, db)
+ for field, value in data.model_dump(exclude_unset=True).items():
+ setattr(course, field, value)
+ course.updated_at = datetime.utcnow()
+ db.commit()
+ db.refresh(course)
+ return {
+ "id": course.id,
+ "title": course.title,
+ "description": course.description,
+ "status": course.status,
+ "updated_at": course.updated_at,
+ }
+
+
+@router.delete("/{course_id}", status_code=204)
+def delete_course(
+ course_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Delete a course (creator or admin)."""
+ course = _course_owner_or_admin(course_id, current_user, db)
+ db.delete(course)
+ db.commit()
+
+
+@router.post("/{course_id}/publish")
+def publish_course(
+ course_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Set course status to published (creator or admin)."""
+ course = _course_owner_or_admin(course_id, current_user, db)
+ course.status = "published"
+ 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,
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Upload a thumbnail image for a course (creator or admin)."""
+ course = _course_owner_or_admin(course_id, current_user, db)
+
+ # Validate content type
+ if not file.content_type or not file.content_type.startswith("image/"):
+ raise HTTPException(status_code=400, detail="File must be an image")
+
+ upload_dir = os.path.join(settings.UPLOAD_DIR, "course_thumbnails")
+ os.makedirs(upload_dir, exist_ok=True)
+
+ ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg"
+ filename = f"{course_id}_{uuid.uuid4().hex[:8]}{ext}"
+ file_path = os.path.join(upload_dir, filename)
+
+ contents = await file.read()
+ with open(file_path, "wb") as f:
+ f.write(contents)
+
+ course.thumbnail_url = f"/uploads/course_thumbnails/{filename}"
+ course.updated_at = datetime.utcnow()
+ db.commit()
+
+ return {"thumbnail_url": course.thumbnail_url}
+
+
+# ── Module CRUD ──────────────────────────────────────────────────────
+
+@router.post("/{course_id}/modules")
+def create_module(
+ course_id: int,
+ data: ModuleCreate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Add a module to a course (creator or admin)."""
+ _course_owner_or_admin(course_id, current_user, db)
+
+ # Auto sort_order if not provided
+ sort_order = data.sort_order
+ if sort_order is None:
+ max_order = (
+ db.query(func.max(CourseModule.sort_order))
+ .filter(CourseModule.course_id == course_id)
+ .scalar()
+ )
+ sort_order = (max_order or 0) + 1
+
+ module = CourseModule(
+ course_id=course_id,
+ title=data.title,
+ description=data.description,
+ sort_order=sort_order,
+ )
+ db.add(module)
+ db.commit()
+ db.refresh(module)
+ return {
+ "id": module.id,
+ "course_id": module.course_id,
+ "title": module.title,
+ "description": module.description,
+ "sort_order": module.sort_order,
+ }
+
+
+@router.put("/{course_id}/modules/{module_id}")
+def update_module(
+ course_id: int,
+ module_id: int,
+ data: ModuleUpdate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Update a module (creator or admin)."""
+ _course_owner_or_admin(course_id, current_user, db)
+ module = (
+ db.query(CourseModule)
+ .filter(CourseModule.id == module_id, CourseModule.course_id == course_id)
+ .first()
+ )
+ if not module:
+ raise HTTPException(status_code=404, detail="Module not found")
+ for field, value in data.model_dump(exclude_unset=True).items():
+ setattr(module, field, value)
+ db.commit()
+ db.refresh(module)
+ return {
+ "id": module.id,
+ "title": module.title,
+ "description": module.description,
+ "sort_order": module.sort_order,
+ }
+
+
+@router.delete("/{course_id}/modules/{module_id}", status_code=204)
+def delete_module(
+ course_id: int,
+ module_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Delete a module and its lessons (creator or admin)."""
+ _course_owner_or_admin(course_id, current_user, db)
+ module = (
+ db.query(CourseModule)
+ .filter(CourseModule.id == module_id, CourseModule.course_id == course_id)
+ .first()
+ )
+ if not module:
+ raise HTTPException(status_code=404, detail="Module not found")
+ db.delete(module)
+ db.commit()
+
+
+@router.put("/{course_id}/modules/reorder")
+def reorder_modules(
+ course_id: int,
+ data: ModuleReorder,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Reorder modules by providing list of module IDs in desired order."""
+ _course_owner_or_admin(course_id, current_user, db)
+ for idx, mod_id in enumerate(data.module_ids):
+ module = (
+ db.query(CourseModule)
+ .filter(CourseModule.id == mod_id, CourseModule.course_id == course_id)
+ .first()
+ )
+ if module:
+ module.sort_order = idx + 1
+ db.commit()
+ return {"reordered": True, "count": len(data.module_ids)}
+
+
+# ── Lesson CRUD ──────────────────────────────────────────────────────
+
+@router.post("/{course_id}/modules/{module_id}/lessons")
+def create_lesson(
+ course_id: int,
+ module_id: int,
+ data: LessonCreate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Add a lesson to a module (creator or admin). Auto-detects video provider."""
+ _course_owner_or_admin(course_id, current_user, db)
+
+ module = (
+ db.query(CourseModule)
+ .filter(CourseModule.id == module_id, CourseModule.course_id == course_id)
+ .first()
+ )
+ if not module:
+ raise HTTPException(status_code=404, detail="Module not found")
+
+ # Auto sort_order if not provided
+ sort_order = data.sort_order
+ if sort_order is None:
+ max_order = (
+ db.query(func.max(CourseLesson.sort_order))
+ .filter(CourseLesson.module_id == module_id)
+ .scalar()
+ )
+ sort_order = (max_order or 0) + 1
+
+ video_provider = _detect_video_provider(data.video_url)
+
+ lesson = CourseLesson(
+ module_id=module_id,
+ title=data.title,
+ lesson_type=data.lesson_type,
+ content_text=data.content_text,
+ video_url=data.video_url,
+ video_provider=video_provider,
+ duration_minutes=data.duration_minutes,
+ is_required=data.is_required,
+ sort_order=sort_order,
+ )
+ db.add(lesson)
+ db.commit()
+ db.refresh(lesson)
+ return {
+ "id": lesson.id,
+ "module_id": lesson.module_id,
+ "title": lesson.title,
+ "lesson_type": lesson.lesson_type,
+ "video_url": lesson.video_url,
+ "video_provider": lesson.video_provider,
+ "sort_order": lesson.sort_order,
+ }
+
+
+@router.put("/{course_id}/lessons/{lesson_id}")
+def update_lesson(
+ course_id: int,
+ lesson_id: int,
+ data: LessonUpdate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Update a lesson (creator or admin)."""
+ _course_owner_or_admin(course_id, current_user, db)
+ lesson = (
+ db.query(CourseLesson)
+ .join(CourseModule, CourseLesson.module_id == CourseModule.id)
+ .filter(CourseLesson.id == lesson_id, CourseModule.course_id == course_id)
+ .first()
+ )
+ if not lesson:
+ raise HTTPException(status_code=404, detail="Lesson not found")
+
+ updates = data.model_dump(exclude_unset=True)
+ # Re-detect video provider if video_url changed
+ if "video_url" in updates and "video_provider" not in updates:
+ updates["video_provider"] = _detect_video_provider(updates["video_url"])
+
+ for field, value in updates.items():
+ setattr(lesson, field, value)
+ db.commit()
+ db.refresh(lesson)
+ return {
+ "id": lesson.id,
+ "title": lesson.title,
+ "lesson_type": lesson.lesson_type,
+ "video_url": lesson.video_url,
+ "video_provider": lesson.video_provider,
+ "sort_order": lesson.sort_order,
+ }
+
+
+@router.delete("/{course_id}/lessons/{lesson_id}", status_code=204)
+def delete_lesson(
+ course_id: int,
+ lesson_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Delete a lesson (creator or admin)."""
+ _course_owner_or_admin(course_id, current_user, db)
+ lesson = (
+ db.query(CourseLesson)
+ .join(CourseModule, CourseLesson.module_id == CourseModule.id)
+ .filter(CourseLesson.id == lesson_id, CourseModule.course_id == course_id)
+ .first()
+ )
+ if not lesson:
+ raise HTTPException(status_code=404, detail="Lesson not found")
+ db.delete(lesson)
+ db.commit()
+
+
+@router.post("/{course_id}/lessons/{lesson_id}/upload")
+async def upload_lesson_file(
+ course_id: int,
+ lesson_id: int,
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Upload a video or document file for a lesson (creator or admin)."""
+ _course_owner_or_admin(course_id, current_user, db)
+ lesson = (
+ db.query(CourseLesson)
+ .join(CourseModule, CourseLesson.module_id == CourseModule.id)
+ .filter(CourseLesson.id == lesson_id, CourseModule.course_id == course_id)
+ .first()
+ )
+ if not lesson:
+ raise HTTPException(status_code=404, detail="Lesson not found")
+
+ upload_dir = os.path.join(settings.UPLOAD_DIR, "course_files", str(course_id))
+ os.makedirs(upload_dir, exist_ok=True)
+
+ ext = os.path.splitext(file.filename or "file")[1]
+ filename = f"{lesson_id}_{uuid.uuid4().hex[:8]}{ext}"
+ file_path = os.path.join(upload_dir, filename)
+
+ contents = await file.read()
+ if len(contents) > settings.MAX_UPLOAD_SIZE:
+ raise HTTPException(status_code=413, detail="File too large")
+
+ with open(file_path, "wb") as f:
+ f.write(contents)
+
+ lesson.file_path = f"/uploads/course_files/{course_id}/{filename}"
+ if not lesson.video_url:
+ lesson.video_provider = "local"
+ db.commit()
+
+ return {"file_path": lesson.file_path}
+
+
+# ── Enrollment ───────────────────────────────────────────────────────
+
+@router.post("/{course_id}/enroll")
+def enroll(
+ course_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Enroll in a published course."""
+ course = db.query(Course).filter(Course.id == course_id).first()
+ if not course:
+ raise HTTPException(status_code=404, detail="Course not found")
+ if course.status != "published":
+ raise HTTPException(status_code=400, detail="Course is not published")
+
+ # Subscription gate (placeholder for future Stripe)
+ if course.requires_subscription:
+ raise HTTPException(status_code=402, detail="This course requires a subscription")
+
+ # No double enrollment
+ existing = (
+ db.query(CourseEnrollment)
+ .filter(
+ CourseEnrollment.course_id == course_id,
+ CourseEnrollment.user_id == current_user.id,
+ )
+ .first()
+ )
+ if existing:
+ raise HTTPException(status_code=409, detail="Already enrolled")
+
+ enrollment = CourseEnrollment(
+ course_id=course_id,
+ user_id=current_user.id,
+ )
+ db.add(enrollment)
+ db.commit()
+ db.refresh(enrollment)
+ return {
+ "enrollment_id": enrollment.id,
+ "course_id": course_id,
+ "enrolled_at": enrollment.enrolled_at,
+ }
+
+
+@router.delete("/{course_id}/enroll", status_code=204)
+def unenroll(
+ course_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Unenroll from a course."""
+ enrollment = (
+ db.query(CourseEnrollment)
+ .filter(
+ CourseEnrollment.course_id == course_id,
+ CourseEnrollment.user_id == current_user.id,
+ )
+ .first()
+ )
+ if not enrollment:
+ raise HTTPException(status_code=404, detail="Not enrolled")
+ db.delete(enrollment)
+ db.commit()
+
+
+# ── Progress tracking ────────────────────────────────────────────────
+
+@router.put("/{course_id}/lessons/{lesson_id}/progress")
+def update_progress(
+ course_id: int,
+ lesson_id: int,
+ data: ProgressUpdate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Update lesson progress. Auto-calculates enrollment progress_pct."""
+ enrollment = (
+ db.query(CourseEnrollment)
+ .filter(
+ CourseEnrollment.course_id == course_id,
+ CourseEnrollment.user_id == current_user.id,
+ )
+ .first()
+ )
+ if not enrollment:
+ raise HTTPException(status_code=403, detail="Not enrolled in this course")
+
+ # Verify lesson belongs to this course
+ lesson = (
+ db.query(CourseLesson)
+ .join(CourseModule, CourseLesson.module_id == CourseModule.id)
+ .filter(CourseLesson.id == lesson_id, CourseModule.course_id == course_id)
+ .first()
+ )
+ if not lesson:
+ raise HTTPException(status_code=404, detail="Lesson not found in this course")
+
+ # Upsert lesson progress
+ progress = (
+ db.query(CourseLessonProgress)
+ .filter(
+ CourseLessonProgress.enrollment_id == enrollment.id,
+ CourseLessonProgress.lesson_id == lesson_id,
+ )
+ .first()
+ )
+ if not progress:
+ progress = CourseLessonProgress(
+ enrollment_id=enrollment.id,
+ lesson_id=lesson_id,
+ )
+ db.add(progress)
+
+ if data.status is not None:
+ progress.status = data.status
+ 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
+ if data.score is not None:
+ progress.score = data.score
+
+ db.flush()
+
+ # Recalculate enrollment progress_pct
+ total_required = (
+ db.query(func.count(CourseLesson.id))
+ .join(CourseModule, CourseLesson.module_id == CourseModule.id)
+ .filter(CourseModule.course_id == course_id, CourseLesson.is_required == 1)
+ .scalar()
+ ) or 1
+
+ completed_required = (
+ db.query(func.count(CourseLessonProgress.id))
+ .join(CourseLesson, CourseLessonProgress.lesson_id == CourseLesson.id)
+ .join(CourseModule, CourseLesson.module_id == CourseModule.id)
+ .filter(
+ CourseLessonProgress.enrollment_id == enrollment.id,
+ CourseModule.course_id == course_id,
+ CourseLesson.is_required == 1,
+ CourseLessonProgress.status == "completed",
+ )
+ .scalar()
+ ) or 0
+
+ enrollment.progress_pct = round((completed_required / total_required) * 100, 1)
+ if enrollment.progress_pct >= 100 and not enrollment.completed_at:
+ enrollment.completed_at = datetime.utcnow()
+
+ db.commit()
+
+ return {
+ "lesson_id": lesson_id,
+ "status": progress.status,
+ "time_spent": progress.time_spent,
+ "score": progress.score,
+ "enrollment_progress_pct": enrollment.progress_pct,
+ }
+
+
+# ── AI content generation ────────────────────────────────────────────
+
+@router.post("/{course_id}/lessons/{lesson_id}/ai-generate")
+def ai_generate_content(
+ course_id: int,
+ lesson_id: int,
+ data: AIGenerateRequest,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Generate or refine lesson text content using AI."""
+ _course_owner_or_admin(course_id, current_user, db)
+
+ lesson = (
+ db.query(CourseLesson)
+ .join(CourseModule, CourseLesson.module_id == CourseModule.id)
+ .filter(CourseLesson.id == lesson_id, CourseModule.course_id == course_id)
+ .first()
+ )
+ if not lesson:
+ raise HTTPException(status_code=404, detail="Lesson not found in this course")
+
+ 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.action == "refine" and data.existing_content:
+ system_msg = (
+ "You are a medical education content specialist. "
+ "Refine the following lesson content based on the user's instructions. "
+ "Maintain accuracy and a clear, professional tone suitable for medical learners."
+ )
+ user_msg = (
+ f"Lesson title: {lesson.title}\n\n"
+ f"Existing content:\n{data.existing_content}\n\n"
+ f"Instructions: {data.prompt}"
+ )
+ elif data.action == "summarize" and data.existing_content:
+ system_msg = (
+ "You are a medical education content specialist. "
+ "Summarize the following lesson content concisely while preserving key medical facts."
+ )
+ user_msg = (
+ f"Lesson title: {lesson.title}\n\n"
+ f"Content to summarize:\n{data.existing_content}\n\n"
+ f"Additional instructions: {data.prompt}"
+ )
+ 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."
+ )
+ user_msg = (
+ f"Lesson title: {lesson.title}\n\n"
+ f"Instructions: {data.prompt}"
+ )
+
+ try:
+ kwargs = {
+ "model": _proxy_model(model_id),
+ "messages": [
+ {"role": "system", "content": system_msg},
+ {"role": "user", "content": user_msg},
+ ],
+ "max_tokens": 4000,
+ }
+ if api_key:
+ kwargs["api_key"] = api_key
+ if settings.LITELLM_API_BASE:
+ kwargs["api_base"] = settings.LITELLM_API_BASE
+
+ response = litellm.completion(**kwargs)
+ generated = response.choices[0].message.content
+ except Exception as e:
+ logger.error(f"AI generation failed: {e}")
+ raise HTTPException(status_code=502, detail="AI generation failed")
+
+ return {
+ "lesson_id": lesson_id,
+ "generated_content": generated,
+ "model_used": model_id,
+ }
+
+
+# ── BBB integration ──────────────────────────────────────────────────
+
+@router.post("/{course_id}/lessons/{lesson_id}/bbb/create")
+def bbb_create_meeting(
+ course_id: int,
+ lesson_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Create a BigBlueButton meeting for a live lesson (creator or admin)."""
+ _course_owner_or_admin(course_id, current_user, db)
+
+ lesson = (
+ db.query(CourseLesson)
+ .join(CourseModule, CourseLesson.module_id == CourseModule.id)
+ .filter(CourseLesson.id == lesson_id, CourseModule.course_id == course_id)
+ .first()
+ )
+ if not lesson:
+ raise HTTPException(status_code=404, detail="Lesson not found 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")
+
+ meeting_id = lesson.bbb_meeting_id or f"pedshub-{course_id}-{lesson_id}"
+ params = urlencode({
+ "name": lesson.title,
+ "meetingID": meeting_id,
+ "attendeePW": "ap",
+ "moderatorPW": "mp",
+ })
+ checksum = _bbb_checksum("create", params, bbb_secret)
+ create_url = f"{bbb_url.rstrip('/')}/api/create?{params}&checksum={checksum}"
+
+ # Call BBB API
+ import httpx
+ try:
+ resp = httpx.get(create_url, timeout=10)
+ resp.raise_for_status()
+ except Exception as e:
+ logger.error(f"BBB create failed: {e}")
+ raise HTTPException(status_code=502, detail="Failed to create BBB meeting")
+
+ lesson.bbb_meeting_id = meeting_id
+ db.commit()
+
+ return {"meeting_id": meeting_id, "create_url": create_url}
+
+
+@router.get("/{course_id}/lessons/{lesson_id}/bbb/join")
+def bbb_join_meeting(
+ course_id: int,
+ lesson_id: int,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Generate a BBB join URL for the current user."""
+ lesson = (
+ db.query(CourseLesson)
+ .join(CourseModule, CourseLesson.module_id == CourseModule.id)
+ .filter(CourseLesson.id == lesson_id, CourseModule.course_id == course_id)
+ .first()
+ )
+ if not lesson:
+ raise HTTPException(status_code=404, detail="Lesson not found in this course")
+ if not lesson.bbb_meeting_id:
+ raise HTTPException(status_code=400, detail="No BBB meeting created for this lesson")
+
+ 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)
+ password = "mp" if is_moderator else "ap"
+
+ params = urlencode({
+ "fullName": current_user.name,
+ "meetingID": lesson.bbb_meeting_id,
+ "password": password,
+ })
+ checksum = _bbb_checksum("join", params, bbb_secret)
+ join_url = f"{bbb_url.rstrip('/')}/api/join?{params}&checksum={checksum}"
+
+ return {"join_url": join_url}
diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py
index f365a93..e518277 100644
--- a/backend/app/routers/questions.py
+++ b/backend/app/routers/questions.py
@@ -221,6 +221,60 @@ def get_question_bank(
return {"total": total, "questions": result}
+class ManualQuestionCreate(BaseModel):
+ question_text: str
+ question_type: str = "mcq"
+ options: list[str] | None = None
+ correct_answer: str
+ explanation: str | None = None
+ question_category_id: int | None = None
+
+
+@router.post("/create")
+def create_question_manually(
+ data: ManualQuestionCreate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Create a single question manually (not from PDF extraction)."""
+ if not data.question_text.strip():
+ 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")
+ if data.question_type == "mcq" and (not data.options or len(data.options) < 2):
+ raise HTTPException(status_code=400, detail="MCQ questions need at least 2 options")
+ if data.question_type == "mcq" and data.correct_answer not in data.options:
+ raise HTTPException(status_code=400, detail="Correct answer must be one of the options")
+
+ question = Question(
+ question_text=data.question_text.strip(),
+ question_type=data.question_type,
+ options=data.options,
+ correct_answer=data.correct_answer.strip(),
+ explanation=data.explanation,
+ question_category_id=data.question_category_id,
+ )
+ db.add(question)
+ db.commit()
+ db.refresh(question)
+
+ # Generate embedding in background
+ try:
+ from app.services import embedding_service
+ embedding_service.embed_question(question)
+ db.commit()
+ except Exception:
+ pass
+
+ return {
+ "id": question.id,
+ "question_text": question.question_text,
+ "question_type": question.question_type,
+ "options": question.options,
+ "correct_answer": question.correct_answer,
+ }
+
+
class CreateFromBankRequest(BaseModel):
title: str
question_ids: list[int]
@@ -232,7 +286,7 @@ class CreateFromBankRequest(BaseModel):
def create_quiz_from_bank(
data: CreateFromBankRequest,
db: Session = Depends(get_db),
- current_user: User = Depends(require_moderator),
+ current_user: User = Depends(get_current_user),
):
"""Create a new quiz referencing existing bank questions (no copying — edits propagate)."""
if not data.title.strip():
@@ -252,7 +306,9 @@ def create_quiz_from_bank(
# Find a section_id from the source questions' origin quiz
first_src_quiz_id = source_questions[0].source_quiz_id
first_quiz = db.query(Quiz).filter(Quiz.id == first_src_quiz_id).first() if first_src_quiz_id else None
- section_id = first_quiz.section_id if first_quiz else source_questions[0].source_quiz_id or 1
+ section_id = first_quiz.section_id if first_quiz else None
+
+ is_mod = current_user.role in ("admin", "moderator")
new_quiz = Quiz(
section_id=section_id,
@@ -261,6 +317,8 @@ def create_quiz_from_bank(
questions_count=len(source_questions),
mode=data.mode,
time_limit_minutes=data.time_limit_minutes,
+ is_published=1 if is_mod else 0,
+ is_shared=1 if is_mod else 0,
)
db.add(new_quiz)
db.flush()
diff --git a/backend/app/schemas/course.py b/backend/app/schemas/course.py
new file mode 100644
index 0000000..69b1e15
--- /dev/null
+++ b/backend/app/schemas/course.py
@@ -0,0 +1,135 @@
+from datetime import datetime
+from pydantic import BaseModel
+
+
+class CourseCreate(BaseModel):
+ title: str
+ description: str | None = None
+
+
+class CourseUpdate(BaseModel):
+ title: str | None = None
+ description: str | None = None
+ status: str | None = None
+ is_featured: int | None = None
+ requires_subscription: int | None = None
+
+
+class ModuleCreate(BaseModel):
+ title: str
+ description: str | None = None
+
+
+class ModuleUpdate(BaseModel):
+ title: str | None = None
+ description: str | None = None
+ position: int | None = None
+
+
+class LessonCreate(BaseModel):
+ title: str
+ lesson_type: str # text, video, document, quiz, live_session
+ description: str | None = None
+ content_text: str | None = None
+ video_url: str | None = None
+ local_file_path: str | None = None
+ quiz_id: int | None = None
+ live_session_url: str | None = None
+ live_session_start: datetime | None = None
+ live_session_end: datetime | None = None
+ duration_minutes: int | None = None
+ is_required: int = 1
+
+
+class LessonUpdate(BaseModel):
+ title: str | None = None
+ description: str | None = None
+ content_text: str | None = None
+ video_url: str | None = None
+ local_file_path: str | None = None
+ quiz_id: int | None = None
+ live_session_url: str | None = None
+ live_session_start: datetime | None = None
+ live_session_end: datetime | None = None
+ duration_minutes: int | None = None
+ is_required: int | None = None
+ position: int | None = None
+
+
+class LessonResponse(BaseModel):
+ id: int
+ module_id: int
+ title: str
+ description: str | None = None
+ position: int
+ lesson_type: str
+ content_text: str | None = None
+ video_url: str | None = None
+ video_provider: str | None = None
+ local_file_path: str | None = None
+ quiz_id: int | None = None
+ live_session_url: str | None = None
+ live_session_start: datetime | None = None
+ live_session_end: datetime | None = None
+ bbb_meeting_id: str | None = None
+ duration_minutes: int | None = None
+ is_required: int = 1
+ class Config:
+ from_attributes = True
+
+
+class ModuleResponse(BaseModel):
+ id: int
+ course_id: int
+ title: str
+ description: str | None = None
+ position: int
+ lessons: list[LessonResponse] = []
+ class Config:
+ from_attributes = True
+
+
+class CourseResponse(BaseModel):
+ id: int
+ title: str
+ description: str | None = None
+ thumbnail_path: str | None = None
+ user_id: int
+ status: str
+ is_featured: int = 0
+ requires_subscription: int = 0
+ created_at: datetime
+ updated_at: datetime | None = None
+ class Config:
+ from_attributes = True
+
+
+class CourseDetail(CourseResponse):
+ modules: list[ModuleResponse] = []
+ creator_name: str | None = None
+ enrolled_count: int = 0
+ my_enrollment: dict | None = None
+
+
+class EnrollmentResponse(BaseModel):
+ id: int
+ course_id: int
+ user_id: int
+ enrolled_at: datetime
+ completed_at: datetime | None = None
+ progress_pct: float = 0.0
+ status: str
+ class Config:
+ from_attributes = True
+
+
+class LessonProgressUpdate(BaseModel):
+ status: str | None = None # in_progress, completed
+ time_spent_seconds: int | None = None
+ score: float | None = None
+
+
+class AIContentRequest(BaseModel):
+ prompt: str
+ action: str = "generate" # generate, refine
+ existing_content: str | None = None
diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py
index 2316ea2..8326de0 100644
--- a/backend/app/schemas/quiz.py
+++ b/backend/app/schemas/quiz.py
@@ -52,6 +52,7 @@ class QuizResponse(BaseModel):
created_at: datetime
deleted_at: datetime | None = None
is_published: int = 1
+ is_shared: int = 0
class Config:
from_attributes = True
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 979d252..28e8cda 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -24,6 +24,9 @@ import NotFoundPage from './pages/NotFoundPage'
import LandingPage from './pages/LandingPage'
import FlashcardsPage from './pages/FlashcardsPage'
import FlashcardStudyPage from './pages/FlashcardStudyPage'
+import CoursesPage from './pages/CoursesPage'
+import CourseDetailPage from './pages/CourseDetailPage'
+import CourseEditorPage from './pages/CourseEditorPage'
// Layout wrapper for authenticated app pages (Navbar + container + footer)
function AppLayout() {
@@ -76,6 +79,9 @@ function AppRoutes() {
{course.description}
+ )} ++ Created by {course.creator_name || 'Unknown'} +
+Enroll in this course to access modules and lessons.
+{activeLesson.duration_minutes} min
+ )} +No video source configured.
+ )} +This lesson contains a quiz.
+ {activeLesson.quiz_id ? ( + + ) : ( +No quiz linked to this lesson.
+ )} ++ Scheduled: {new Date(activeLesson.scheduled_at).toLocaleString()} +
+ )} + {activeLesson.live_session_url ? ( + + Join Session + + ) : activeLesson.bbb_meeting_id ? ( + + ) : ( +No session link available yet.
+ )} +No document attached.
+ )} +SCORM package viewer is not yet available.
+Course not found.
+ No modules yet. Add your first module to start building the course. +
+ )} + + {modules.map((mod, modIdx) => { + const isExpanded = expandedModule === mod.id + const lessons = mod.lessons || [] + + return ( ++ No lessons in this module. +
+ )} + + {lessons.map((lesson, lesIdx) => ( +{total} course{total !== 1 ? 's' : ''}
+ + {courses.length === 0 && !loading && ( ++ {course.description.length > 120 ? course.description.slice(0, 120) + '...' : course.description} +
+ )} ++ {course.description.length > 200 ? course.description.slice(0, 200) + '...' : course.description} +
+ )} ++ {course.description.length > 200 ? course.description.slice(0, 200) + '...' : course.description} +
+ )} +{total} questions total