2026-02-04 16:50:33 +02:00

283 lines
11 KiB
Python

from datetime import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.deps import get_db, get_current_user
from app.models import Course, Enrollment, Module, ModuleProgress, ModuleType, ProgressStatus, QuizAttempt, CourseGroup, GroupMembership, UserRole, Achievement, UserAchievement
from app.schemas import CourseOut, CourseWithModules, EnrollOut, ModuleWithProgress, QuizResult, QuizSubmit, ProgressOut, AchievementOut
from app.services.progression import ensure_module_progress, recompute_progress, complete_content_module
from app.services.quiz import grade_quiz
from app.achievements import check_and_award_achievements
router = APIRouter(tags=["learner"])
@router.get("/courses", response_model=List[CourseOut])
def list_courses(db: Session = Depends(get_db), user=Depends(get_current_user)):
if user.role == UserRole.admin:
return db.query(Course).order_by(Course.created_at.desc()).all()
user_group_ids = [
gm.group_id for gm in db.query(GroupMembership).filter(GroupMembership.user_id == user.id).all()
]
courses = db.query(Course).filter(Course.is_published == True).order_by(Course.created_at.desc()).all()
allowed = []
for course in courses:
group_ids = [cg.group_id for cg in course.course_groups]
if not group_ids:
allowed.append(course)
elif any(gid in user_group_ids for gid in group_ids):
allowed.append(course)
return allowed
@router.post("/courses/{course_id}/enroll", response_model=EnrollOut)
def enroll(course_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
course = db.get(Course, course_id)
if not course:
raise HTTPException(status_code=404, detail="Course not found")
if user.role != UserRole.admin:
if not course.is_published:
raise HTTPException(status_code=404, detail="Course not found")
group_ids = [cg.group_id for cg in course.course_groups]
if group_ids:
user_group_ids = [
gm.group_id for gm in db.query(GroupMembership).filter(GroupMembership.user_id == user.id).all()
]
if not any(gid in user_group_ids for gid in group_ids):
raise HTTPException(status_code=403, detail="Not allowed")
existing = (
db.query(Enrollment)
.filter(Enrollment.user_id == user.id, Enrollment.course_id == course_id)
.first()
)
if existing:
return EnrollOut(course_id=course_id, enrolled_at=existing.enrolled_at)
enrollment = Enrollment(user_id=user.id, course_id=course_id)
db.add(enrollment)
db.flush()
ensure_module_progress(db, user.id, course_id)
recompute_progress(db, user.id, course_id)
db.refresh(enrollment)
return EnrollOut(course_id=course_id, enrolled_at=enrollment.enrolled_at)
@router.get("/courses/{course_id}", response_model=CourseWithModules)
def get_course(course_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
course = db.get(Course, course_id)
if not course:
raise HTTPException(status_code=404, detail="Course not found")
if user.role != UserRole.admin:
if not course.is_published:
raise HTTPException(status_code=404, detail="Course not found")
group_ids = [cg.group_id for cg in course.course_groups]
if group_ids:
user_group_ids = [
gm.group_id for gm in db.query(GroupMembership).filter(GroupMembership.user_id == user.id).all()
]
if not any(gid in user_group_ids for gid in group_ids):
raise HTTPException(status_code=403, detail="Not allowed")
enrollment = (
db.query(Enrollment)
.filter(Enrollment.user_id == user.id, Enrollment.course_id == course_id)
.first()
)
if not enrollment and user.role != UserRole.admin:
raise HTTPException(status_code=403, detail="Not enrolled")
if not enrollment and user.role == UserRole.admin:
# preview mode for admin without enrolling
modules = db.query(Module).filter(Module.course_id == course_id).order_by(Module.order_index).all()
module_views: List[ModuleWithProgress] = []
for idx, module in enumerate(modules):
status = ProgressStatus.unlocked if idx == 0 else ProgressStatus.locked
module_views.append(
ModuleWithProgress(
id=module.id,
order_index=module.order_index,
type=module.type,
title=module.title,
content_text=module.content_text,
pass_score=module.pass_score,
status=status,
completed_at=None,
score=None,
questions=module.questions,
)
)
return CourseWithModules(
id=course.id,
title=course.title,
description=course.description,
is_published=course.is_published,
modules=module_views,
)
ensure_module_progress(db, user.id, course_id)
recompute_progress(db, user.id, course_id)
modules = db.query(Module).filter(Module.course_id == course_id).order_by(Module.order_index).all()
progress_map = {
p.module_id: p
for p in db.query(ModuleProgress).filter(ModuleProgress.user_id == user.id).all()
}
module_views: List[ModuleWithProgress] = []
for module in modules:
progress = progress_map.get(module.id)
module_views.append(
ModuleWithProgress(
id=module.id,
order_index=module.order_index,
type=module.type,
title=module.title,
content_text=module.content_text,
pass_score=module.pass_score,
status=progress.status if progress else ProgressStatus.locked,
completed_at=progress.completed_at if progress else None,
score=progress.score if progress else None,
questions=module.questions,
)
)
return CourseWithModules(
id=course.id,
title=course.title,
description=course.description,
is_published=course.is_published,
modules=module_views,
)
@router.post("/modules/{module_id}/complete", response_model=QuizResult)
def complete_module(module_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
module = db.get(Module, module_id)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
progress = (
db.query(ModuleProgress)
.filter(ModuleProgress.user_id == user.id, ModuleProgress.module_id == module_id)
.first()
)
if not progress or progress.status != ProgressStatus.unlocked:
raise HTTPException(status_code=403, detail="Module locked")
if module.type != ModuleType.content:
raise HTTPException(status_code=400, detail="Not a content module")
complete_content_module(db, user.id, module)
recompute_progress(db, user.id, module.course_id)
check_and_award_achievements(db, user.id)
return QuizResult(score=100.0, passed=True)
@router.post("/modules/{module_id}/quiz/submit", response_model=QuizResult)
def submit_quiz(module_id: int, payload: QuizSubmit, db: Session = Depends(get_db), user=Depends(get_current_user)):
module = db.get(Module, module_id)
if not module or module.type != ModuleType.quiz:
raise HTTPException(status_code=404, detail="Quiz module not found")
progress = (
db.query(ModuleProgress)
.filter(ModuleProgress.user_id == user.id, ModuleProgress.module_id == module_id)
.first()
)
if not progress or progress.status != ProgressStatus.unlocked:
raise HTTPException(status_code=403, detail="Module locked")
score, _, _ = grade_quiz(db, module, payload.answers)
pass_score = module.pass_score or 80
passed = score >= pass_score
attempt = QuizAttempt(
user_id=user.id,
module_id=module_id,
score=score,
passed=passed,
answers_json=payload.answers,
)
db.add(attempt)
progress.score = score
if passed:
progress.status = ProgressStatus.completed
progress.completed_at = datetime.utcnow()
db.commit()
check_and_award_achievements(db, user.id)
recompute_progress(db, user.id, module.course_id)
return QuizResult(score=score, passed=passed)
@router.get("/progress", response_model=ProgressOut)
def overall_progress(db: Session = Depends(get_db), user=Depends(get_current_user)):
enrollments = db.query(Enrollment).filter(Enrollment.user_id == user.id).all()
courses_out = []
for enrollment in enrollments:
course = db.get(Course, enrollment.course_id)
if not course:
continue
modules = db.query(Module).filter(Module.course_id == course.id).order_by(Module.order_index).all()
if not modules:
continue
progress_map = {
p.module_id: p
for p in db.query(ModuleProgress).filter(ModuleProgress.user_id == user.id).all()
}
completed = [p for p in progress_map.values() if p.status == ProgressStatus.completed]
percent = (len(completed) / len(modules)) * 100
final_module = modules[-1]
final_progress = progress_map.get(final_module.id)
final_passed = final_progress.score is not None and final_progress.score >= (final_module.pass_score or 80)
if final_progress and final_progress.score is not None and not final_passed:
status_value = "failed"
elif final_progress and final_progress.status == ProgressStatus.completed and final_passed:
status_value = "passed"
elif percent >= 100:
status_value = "completed"
else:
status_value = "in_progress"
completed_at = None
if completed:
completed_at = max([p.completed_at for p in completed if p.completed_at is not None], default=None)
courses_out.append(
{
"course_id": course.id,
"title": course.title,
"percent": percent,
"status": status_value,
"final_exam_score": final_progress.score if final_progress else None,
"final_exam_passed": final_passed if final_progress else None,
"completed_at": completed_at,
}
)
@router.get("/me/achievements", response_model=List[AchievementOut])
def my_achievements(db: Session = Depends(get_db), user=Depends(get_current_user)):
all_achievements = db.query(Achievement).all()
user_achievements = {
ua.achievement_id: ua
for ua in db.query(UserAchievement).filter(UserAchievement.user_id == user.id).all()
}
result = []
for achievement in all_achievements:
ua = user_achievements.get(achievement.id)
result.append(
AchievementOut(
id=achievement.id,
code=achievement.code,
name=achievement.name,
description=achievement.description,
earned=ua is not None,
earned_at=ua.earned_at if ua else None,
)
)
return result
return {"courses": courses_out}