Files
012-kaopeilian/backend/app/api/v1/endpoints/progress.py
yuliang_guo 406efa6f14
All checks were successful
continuous-integration/drone/push Build is passing
fix: 修复 endpoints 模块导入路径
- progress.py: get_db, get_current_user 从 app.core.deps 导入
- speech.py: 同上
- recommendation.py: 同上
2026-01-30 15:04:01 +08:00

470 lines
15 KiB
Python

"""
用户课程学习进度 API
"""
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from pydantic import BaseModel, Field
from app.core.deps import get_db, get_current_user
from app.models.user import User
from app.models.course import Course, CourseMaterial
from app.models.user_course_progress import (
UserCourseProgress,
UserMaterialProgress,
ProgressStatus,
)
router = APIRouter()
# ============ Schemas ============
class MaterialProgressUpdate(BaseModel):
"""更新资料进度请求"""
progress_percent: float = Field(ge=0, le=100, description="进度百分比")
last_position: Optional[int] = Field(default=0, ge=0, description="播放位置(秒)")
study_time_delta: Optional[int] = Field(default=0, ge=0, description="本次学习时长(秒)")
is_completed: Optional[bool] = Field(default=None, description="是否标记完成")
class MaterialProgressResponse(BaseModel):
"""资料进度响应"""
material_id: int
material_name: str
is_completed: bool
progress_percent: float
last_position: int
study_time: int
first_accessed_at: Optional[datetime]
last_accessed_at: Optional[datetime]
completed_at: Optional[datetime]
class Config:
from_attributes = True
class CourseProgressResponse(BaseModel):
"""课程进度响应"""
course_id: int
course_name: str
status: str
progress_percent: float
completed_materials: int
total_materials: int
total_study_time: int
first_accessed_at: Optional[datetime]
last_accessed_at: Optional[datetime]
completed_at: Optional[datetime]
materials: List[MaterialProgressResponse] = []
class Config:
from_attributes = True
class ProgressSummary(BaseModel):
"""进度统计摘要"""
total_courses: int
completed_courses: int
in_progress_courses: int
not_started_courses: int
total_study_time: int
average_progress: float
# ============ API Endpoints ============
@router.get("/summary", response_model=ProgressSummary)
async def get_progress_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取用户学习进度摘要"""
# 获取用户所有课程进度
result = await db.execute(
select(UserCourseProgress).where(
UserCourseProgress.user_id == current_user.id
)
)
progress_list = result.scalars().all()
total_courses = len(progress_list)
completed = sum(1 for p in progress_list if p.status == ProgressStatus.COMPLETED.value)
in_progress = sum(1 for p in progress_list if p.status == ProgressStatus.IN_PROGRESS.value)
not_started = sum(1 for p in progress_list if p.status == ProgressStatus.NOT_STARTED.value)
total_time = sum(p.total_study_time for p in progress_list)
avg_progress = sum(p.progress_percent for p in progress_list) / total_courses if total_courses > 0 else 0
return ProgressSummary(
total_courses=total_courses,
completed_courses=completed,
in_progress_courses=in_progress,
not_started_courses=not_started,
total_study_time=total_time,
average_progress=round(avg_progress, 2),
)
@router.get("/courses", response_model=List[CourseProgressResponse])
async def get_all_course_progress(
status: Optional[str] = Query(None, description="过滤状态"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取用户所有课程的学习进度"""
query = select(UserCourseProgress, Course).join(
Course, UserCourseProgress.course_id == Course.id
).where(
UserCourseProgress.user_id == current_user.id
)
if status:
query = query.where(UserCourseProgress.status == status)
result = await db.execute(query)
rows = result.all()
response = []
for progress, course in rows:
response.append(CourseProgressResponse(
course_id=course.id,
course_name=course.name,
status=progress.status,
progress_percent=progress.progress_percent,
completed_materials=progress.completed_materials,
total_materials=progress.total_materials,
total_study_time=progress.total_study_time,
first_accessed_at=progress.first_accessed_at,
last_accessed_at=progress.last_accessed_at,
completed_at=progress.completed_at,
))
return response
@router.get("/courses/{course_id}", response_model=CourseProgressResponse)
async def get_course_progress(
course_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取指定课程的详细学习进度"""
# 获取课程信息
course_result = await db.execute(
select(Course).where(Course.id == course_id)
)
course = course_result.scalar_one_or_none()
if not course:
raise HTTPException(status_code=404, detail="课程不存在")
# 获取或创建课程进度
progress_result = await db.execute(
select(UserCourseProgress).where(
and_(
UserCourseProgress.user_id == current_user.id,
UserCourseProgress.course_id == course_id,
)
)
)
progress = progress_result.scalar_one_or_none()
if not progress:
# 获取课程资料数量
materials_result = await db.execute(
select(func.count(CourseMaterial.id)).where(
and_(
CourseMaterial.course_id == course_id,
CourseMaterial.is_deleted == False,
)
)
)
total_materials = materials_result.scalar() or 0
# 创建新的进度记录
progress = UserCourseProgress(
user_id=current_user.id,
course_id=course_id,
status=ProgressStatus.NOT_STARTED.value,
progress_percent=0.0,
completed_materials=0,
total_materials=total_materials,
)
db.add(progress)
await db.commit()
await db.refresh(progress)
# 获取资料进度
material_progress_result = await db.execute(
select(UserMaterialProgress, CourseMaterial).join(
CourseMaterial, UserMaterialProgress.material_id == CourseMaterial.id
).where(
and_(
UserMaterialProgress.user_id == current_user.id,
UserMaterialProgress.course_id == course_id,
)
)
)
material_rows = material_progress_result.all()
materials = []
for mp, material in material_rows:
materials.append(MaterialProgressResponse(
material_id=material.id,
material_name=material.name,
is_completed=mp.is_completed,
progress_percent=mp.progress_percent,
last_position=mp.last_position,
study_time=mp.study_time,
first_accessed_at=mp.first_accessed_at,
last_accessed_at=mp.last_accessed_at,
completed_at=mp.completed_at,
))
return CourseProgressResponse(
course_id=course.id,
course_name=course.name,
status=progress.status,
progress_percent=progress.progress_percent,
completed_materials=progress.completed_materials,
total_materials=progress.total_materials,
total_study_time=progress.total_study_time,
first_accessed_at=progress.first_accessed_at,
last_accessed_at=progress.last_accessed_at,
completed_at=progress.completed_at,
materials=materials,
)
@router.post("/materials/{material_id}", response_model=MaterialProgressResponse)
async def update_material_progress(
material_id: int,
data: MaterialProgressUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""更新资料学习进度"""
# 获取资料信息
material_result = await db.execute(
select(CourseMaterial).where(CourseMaterial.id == material_id)
)
material = material_result.scalar_one_or_none()
if not material:
raise HTTPException(status_code=404, detail="资料不存在")
course_id = material.course_id
now = datetime.now()
# 获取或创建资料进度
mp_result = await db.execute(
select(UserMaterialProgress).where(
and_(
UserMaterialProgress.user_id == current_user.id,
UserMaterialProgress.material_id == material_id,
)
)
)
mp = mp_result.scalar_one_or_none()
if not mp:
mp = UserMaterialProgress(
user_id=current_user.id,
material_id=material_id,
course_id=course_id,
first_accessed_at=now,
)
db.add(mp)
# 更新进度
mp.progress_percent = data.progress_percent
mp.last_position = data.last_position or mp.last_position
mp.study_time += data.study_time_delta or 0
mp.last_accessed_at = now
# 处理完成状态
if data.is_completed is not None:
if data.is_completed and not mp.is_completed:
mp.is_completed = True
mp.completed_at = now
mp.progress_percent = 100.0
elif not data.is_completed:
mp.is_completed = False
mp.completed_at = None
elif data.progress_percent >= 100:
mp.is_completed = True
mp.completed_at = now
await db.commit()
# 更新课程整体进度
await _update_course_progress(db, current_user.id, course_id)
await db.refresh(mp)
return MaterialProgressResponse(
material_id=mp.material_id,
material_name=material.name,
is_completed=mp.is_completed,
progress_percent=mp.progress_percent,
last_position=mp.last_position,
study_time=mp.study_time,
first_accessed_at=mp.first_accessed_at,
last_accessed_at=mp.last_accessed_at,
completed_at=mp.completed_at,
)
@router.post("/materials/{material_id}/complete")
async def mark_material_complete(
material_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""标记资料为已完成"""
return await update_material_progress(
material_id=material_id,
data=MaterialProgressUpdate(progress_percent=100, is_completed=True),
db=db,
current_user=current_user,
)
@router.post("/courses/{course_id}/start")
async def start_course(
course_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""开始学习课程(记录首次访问)"""
# 获取课程
course_result = await db.execute(
select(Course).where(Course.id == course_id)
)
course = course_result.scalar_one_or_none()
if not course:
raise HTTPException(status_code=404, detail="课程不存在")
now = datetime.now()
# 获取或创建进度
progress_result = await db.execute(
select(UserCourseProgress).where(
and_(
UserCourseProgress.user_id == current_user.id,
UserCourseProgress.course_id == course_id,
)
)
)
progress = progress_result.scalar_one_or_none()
if not progress:
# 获取资料数量
materials_result = await db.execute(
select(func.count(CourseMaterial.id)).where(
and_(
CourseMaterial.course_id == course_id,
CourseMaterial.is_deleted == False,
)
)
)
total_materials = materials_result.scalar() or 0
progress = UserCourseProgress(
user_id=current_user.id,
course_id=course_id,
status=ProgressStatus.IN_PROGRESS.value,
total_materials=total_materials,
first_accessed_at=now,
last_accessed_at=now,
)
db.add(progress)
else:
if progress.status == ProgressStatus.NOT_STARTED.value:
progress.status = ProgressStatus.IN_PROGRESS.value
if not progress.first_accessed_at:
progress.first_accessed_at = now
progress.last_accessed_at = now
await db.commit()
return {"code": 200, "message": "已开始学习", "data": {"course_id": course_id}}
# ============ Helper Functions ============
async def _update_course_progress(db: AsyncSession, user_id: int, course_id: int):
"""更新课程整体进度"""
now = datetime.now()
# 获取课程所有资料数量
materials_result = await db.execute(
select(func.count(CourseMaterial.id)).where(
and_(
CourseMaterial.course_id == course_id,
CourseMaterial.is_deleted == False,
)
)
)
total_materials = materials_result.scalar() or 0
# 获取已完成的资料数量和总学习时长
completed_result = await db.execute(
select(
func.count(UserMaterialProgress.id),
func.coalesce(func.sum(UserMaterialProgress.study_time), 0),
).where(
and_(
UserMaterialProgress.user_id == user_id,
UserMaterialProgress.course_id == course_id,
UserMaterialProgress.is_completed == True,
)
)
)
row = completed_result.one()
completed_materials = row[0]
total_study_time = row[1]
# 计算进度百分比
progress_percent = (completed_materials / total_materials * 100) if total_materials > 0 else 0
# 确定状态
if completed_materials == 0:
status = ProgressStatus.IN_PROGRESS.value # 已开始但未完成任何资料
elif completed_materials >= total_materials:
status = ProgressStatus.COMPLETED.value
else:
status = ProgressStatus.IN_PROGRESS.value
# 获取或创建课程进度
progress_result = await db.execute(
select(UserCourseProgress).where(
and_(
UserCourseProgress.user_id == user_id,
UserCourseProgress.course_id == course_id,
)
)
)
progress = progress_result.scalar_one_or_none()
if not progress:
progress = UserCourseProgress(
user_id=user_id,
course_id=course_id,
first_accessed_at=now,
)
db.add(progress)
# 更新进度
progress.status = status
progress.progress_percent = round(progress_percent, 2)
progress.completed_materials = completed_materials
progress.total_materials = total_materials
progress.total_study_time = total_study_time
progress.last_accessed_at = now
if status == ProgressStatus.COMPLETED.value and not progress.completed_at:
progress.completed_at = now
await db.commit()