pdf-quiz-generator/backend/app/routers/courses.py
Daniel 594bbfaf26 Fix course router: remove nonexistent model fields
- 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>
2026-04-05 02:26:41 +02:00

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}