283 lines
11 KiB
Python
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}
|