1709 lines
59 KiB
Python
1709 lines
59 KiB
Python
"""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, Field
|
|
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.question import Question
|
|
from app.models.quiz import Quiz
|
|
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 = Field(..., min_length=1, max_length=300)
|
|
description: str | None = Field(None, max_length=5000)
|
|
|
|
|
|
class CourseUpdate(BaseModel):
|
|
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 = 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 = Field(None, max_length=300)
|
|
description: str | None = Field(None, max_length=2000)
|
|
position: int | None = None
|
|
|
|
|
|
class ModuleReorder(BaseModel):
|
|
module_ids: list[int]
|
|
|
|
|
|
class LessonCreate(BaseModel):
|
|
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 = Field(None, max_length=300)
|
|
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
|
|
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):
|
|
status: str | None = None # not_started, in_progress, completed
|
|
time_spent: int | None = None # seconds
|
|
score: float | None = None
|
|
|
|
|
|
class AIGenerateRequest(BaseModel):
|
|
model_config = {"protected_namespaces": ()}
|
|
|
|
prompt: str
|
|
action: str = "generate" # generate, refine, summarize
|
|
existing_content: str | None = None
|
|
model_id: str | None = None # optional override model
|
|
|
|
|
|
# ── 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 (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,
|
|
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()
|
|
)
|
|
|
|
# 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:
|
|
# 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,
|
|
"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,
|
|
})
|
|
|
|
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()
|
|
)
|
|
|
|
# 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:
|
|
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,
|
|
"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,
|
|
})
|
|
|
|
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()
|
|
)
|
|
|
|
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,
|
|
"completed_at": enrollment.completed_at,
|
|
})
|
|
|
|
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,
|
|
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.position, CourseModule.id)
|
|
.all()
|
|
)
|
|
modules_out = []
|
|
for mod in modules:
|
|
lessons = (
|
|
db.query(CourseLesson)
|
|
.filter(CourseLesson.module_id == mod.id)
|
|
.order_by(CourseLesson.position, CourseLesson.id)
|
|
.all()
|
|
)
|
|
modules_out.append({
|
|
"id": mod.id,
|
|
"title": mod.title,
|
|
"description": mod.description,
|
|
"position": mod.position,
|
|
"lessons": [
|
|
_build_lesson_dict(les, db, current_user.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_seconds,
|
|
"score": lp.score,
|
|
"completed_at": lp.completed_at,
|
|
}
|
|
for lp in lesson_progress
|
|
},
|
|
}
|
|
|
|
return {
|
|
"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": 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}/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,
|
|
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_path = f"/uploads/course_thumbnails/{filename}"
|
|
course.updated_at = datetime.utcnow()
|
|
db.commit()
|
|
|
|
return {"thumbnail_path": course.thumbnail_path}
|
|
|
|
|
|
# ── 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 position if not provided
|
|
position = data.position
|
|
if position is None:
|
|
max_order = (
|
|
db.query(func.max(CourseModule.position))
|
|
.filter(CourseModule.course_id == course_id)
|
|
.scalar()
|
|
)
|
|
position = (max_order or 0) + 1
|
|
|
|
module = CourseModule(
|
|
course_id=course_id,
|
|
title=data.title,
|
|
description=data.description,
|
|
position=position,
|
|
)
|
|
db.add(module)
|
|
db.commit()
|
|
db.refresh(module)
|
|
return {
|
|
"id": module.id,
|
|
"course_id": module.course_id,
|
|
"title": module.title,
|
|
"description": module.description,
|
|
"position": module.position,
|
|
}
|
|
|
|
|
|
@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,
|
|
"position": module.position,
|
|
}
|
|
|
|
|
|
@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.position = idx + 1
|
|
db.commit()
|
|
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")
|
|
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 position if not provided
|
|
position = data.position
|
|
if position is None:
|
|
max_order = (
|
|
db.query(func.max(CourseLesson.position))
|
|
.filter(CourseLesson.module_id == module_id)
|
|
.scalar()
|
|
)
|
|
position = (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,
|
|
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,
|
|
)
|
|
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,
|
|
"position": lesson.position,
|
|
}
|
|
|
|
|
|
@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,
|
|
"position": lesson.position,
|
|
}
|
|
|
|
|
|
@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.local_file_path = f"/uploads/course_files/{course_id}/{filename}"
|
|
if not lesson.video_url:
|
|
lesson.video_provider = "local"
|
|
db.commit()
|
|
|
|
return {"local_file_path": lesson.local_file_path}
|
|
|
|
|
|
# ── SCORM ────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/{course_id}/lessons/{lesson_id}/scorm")
|
|
async def upload_scorm_package(
|
|
course_id: int,
|
|
lesson_id: int,
|
|
file: UploadFile = File(...),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Upload a SCORM package (ZIP) for a lesson."""
|
|
import zipfile
|
|
import xml.etree.ElementTree as ET
|
|
|
|
_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")
|
|
|
|
if not file.filename or not file.filename.lower().endswith(".zip"):
|
|
raise HTTPException(status_code=400, detail="SCORM package must be a ZIP file")
|
|
|
|
contents = await file.read()
|
|
if len(contents) > 200 * 1024 * 1024: # 200MB limit for SCORM
|
|
raise HTTPException(status_code=413, detail="SCORM package too large (max 200MB)")
|
|
|
|
# Extract to scorm directory
|
|
scorm_dir = os.path.join(settings.UPLOAD_DIR, "scorm", str(course_id), str(lesson_id))
|
|
if os.path.exists(scorm_dir):
|
|
import shutil
|
|
shutil.rmtree(scorm_dir)
|
|
os.makedirs(scorm_dir, exist_ok=True)
|
|
|
|
import io
|
|
try:
|
|
with zipfile.ZipFile(io.BytesIO(contents)) as zf:
|
|
zf.extractall(scorm_dir)
|
|
except zipfile.BadZipFile:
|
|
raise HTTPException(status_code=400, detail="Invalid ZIP file")
|
|
|
|
# Parse imsmanifest.xml to find launch file
|
|
manifest_path = os.path.join(scorm_dir, "imsmanifest.xml")
|
|
if not os.path.exists(manifest_path):
|
|
import shutil
|
|
shutil.rmtree(scorm_dir)
|
|
raise HTTPException(status_code=400, detail="Not a valid SCORM package — missing imsmanifest.xml")
|
|
|
|
launch_file = None
|
|
try:
|
|
tree = ET.parse(manifest_path)
|
|
root = tree.getroot()
|
|
ns = {"": root.tag.split("}")[0] + "}"} if "}" in root.tag else {}
|
|
prefix = ns.get("", "")
|
|
# Find first resource with href
|
|
for resource in root.iter(f"{prefix}resource"):
|
|
href = resource.get("href")
|
|
if href:
|
|
launch_file = href
|
|
break
|
|
except Exception:
|
|
logger.warning("Failed to parse SCORM manifest for lesson %d", lesson_id, exc_info=True)
|
|
|
|
if not launch_file:
|
|
launch_file = "index.html" # fallback
|
|
|
|
scorm_url = f"/uploads/scorm/{course_id}/{lesson_id}"
|
|
lesson.local_file_path = f"{scorm_url}/{launch_file}"
|
|
lesson.lesson_type = "scorm_package"
|
|
db.commit()
|
|
|
|
return {
|
|
"scorm_url": scorm_url,
|
|
"launch_file": launch_file,
|
|
"full_url": f"{scorm_url}/{launch_file}",
|
|
}
|
|
|
|
|
|
@router.get("/{course_id}/lessons/{lesson_id}/scorm/status")
|
|
def get_scorm_status(
|
|
course_id: int,
|
|
lesson_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Get SCORM completion status for this user/lesson."""
|
|
enrollment = db.query(CourseEnrollment).filter(
|
|
CourseEnrollment.course_id == course_id,
|
|
CourseEnrollment.user_id == current_user.id,
|
|
).first()
|
|
if not enrollment:
|
|
return {"completed": False}
|
|
progress = (
|
|
db.query(CourseLessonProgress)
|
|
.filter(CourseLessonProgress.enrollment_id == enrollment.id, CourseLessonProgress.lesson_id == lesson_id)
|
|
.first()
|
|
)
|
|
return {"completed": progress.status == "completed" if progress else False}
|
|
|
|
|
|
@router.get("/{course_id}/lessons/{lesson_id}/scorm/data")
|
|
def get_scorm_data(
|
|
course_id: int,
|
|
lesson_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Get saved SCORM runtime data for this user/lesson."""
|
|
import json
|
|
try:
|
|
import redis as redis_lib
|
|
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
|
key = f"scorm_data:{current_user.id}:{course_id}:{lesson_id}"
|
|
data = r.get(key)
|
|
return json.loads(data) if data else {}
|
|
except Exception:
|
|
logger.warning("Redis unavailable for SCORM data retrieval", exc_info=True)
|
|
return {}
|
|
|
|
|
|
@router.put("/{course_id}/lessons/{lesson_id}/scorm/data")
|
|
def save_scorm_data(
|
|
course_id: int,
|
|
lesson_id: int,
|
|
data: dict,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Save SCORM runtime data for this user/lesson. Persists bookmarks, scores, and state."""
|
|
import json
|
|
try:
|
|
import redis as redis_lib
|
|
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
|
key = f"scorm_data:{current_user.id}:{course_id}:{lesson_id}"
|
|
r.set(key, json.dumps(data), ex=60 * 60 * 24 * 90) # 90 day expiry
|
|
return {"saved": True}
|
|
except Exception:
|
|
raise HTTPException(status_code=500, detail="Failed to save SCORM data")
|
|
|
|
|
|
# ── 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,
|
|
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_seconds = (progress.time_spent_seconds 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_seconds,
|
|
"score": progress.score,
|
|
"enrollment_progress_pct": enrollment.progress_pct,
|
|
}
|
|
|
|
|
|
# ── Certificate ──────────────────────────────────────────────────────
|
|
|
|
@router.get("/{course_id}/certificate")
|
|
def download_certificate(
|
|
course_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Generate and return a completion certificate PDF."""
|
|
from starlette.responses import FileResponse
|
|
|
|
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 in this course")
|
|
if not enrollment.completed_at:
|
|
raise HTTPException(status_code=400, detail="Course not yet completed")
|
|
|
|
course = db.query(Course).filter(Course.id == course_id).first()
|
|
if not course:
|
|
raise HTTPException(status_code=404, detail="Course not found")
|
|
|
|
from app.services.certificate_service import generate_certificate
|
|
cert_path = generate_certificate(
|
|
student_name=current_user.name or current_user.email,
|
|
course_title=course.title,
|
|
completed_at=enrollment.completed_at,
|
|
upload_dir=settings.UPLOAD_DIR,
|
|
)
|
|
|
|
full_path = os.path.join(settings.UPLOAD_DIR, "certificates", os.path.basename(cert_path))
|
|
return FileResponse(
|
|
full_path,
|
|
media_type="application/pdf",
|
|
filename=f"Certificate - {course.title}.pdf",
|
|
)
|
|
|
|
|
|
# ── AI content generation ────────────────────────────────────────────
|
|
|
|
@router.post("/{course_id}/ai-generate")
|
|
def ai_generate_standalone(
|
|
course_id: int,
|
|
data: AIGenerateRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Generate content using AI without requiring a saved lesson."""
|
|
_course_owner_or_admin(course_id, current_user, db)
|
|
|
|
from app.services.ai_service import get_model_for_task, _proxy_model
|
|
import litellm
|
|
|
|
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 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:
|
|
user_msg = f"Existing content:\n{data.existing_content}\n\nInstructions: {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
|
|
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)
|
|
return {"generated_content": response.choices[0].message.content, "model_used": model_id}
|
|
except Exception as e:
|
|
logger.error(f"AI generation failed: {e}")
|
|
raise HTTPException(status_code=502, detail="AI generation failed")
|
|
|
|
|
|
@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
|
|
|
|
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 = (
|
|
"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 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"
|
|
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
|
|
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)
|
|
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")
|
|
|
|
# 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
|
|
is_moderator = is_owner
|
|
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}
|