- Remove category, thumbnail_url references (not on Course model) - Fix sort_order → position (matching actual column name) - Fix CourseCreate schema: only title + description - Fix tab labels: "My Created" → "Created by Me" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1082 lines
35 KiB
Python
1082 lines
35 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
|
|
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
|
|
|
|
|
|
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
|
|
position: int | None = None
|
|
|
|
|
|
class ModuleUpdate(BaseModel):
|
|
title: str | None = None
|
|
description: str | None = None
|
|
position: 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
|
|
position: 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
|
|
position: 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,
|
|
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,
|
|
|
|
"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),
|
|
"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,
|
|
|
|
"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),
|
|
"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,
|
|
|
|
"thumbnail_path": course.thumbnail_path,
|
|
"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.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": [
|
|
{
|
|
"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,
|
|
"position": les.position,
|
|
"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,
|
|
"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}/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)}
|
|
|
|
|
|
# ── 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,
|
|
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.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}
|