Files
chunyu_project/learn/views.py
T

775 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
from django.conf import settings
from django.db.models import Count
from django.utils import timezone
from django.http import FileResponse, Http404
from django.shortcuts import get_object_or_404
from rest_framework import generics, status
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
from rest_framework.views import APIView
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from chunyu_project.common_schemas import success_response, error_response, unauthorized_response, not_found_response
from utils.response_codes import ResponseCode, create_standardized_response, create_standardized_error_response
from .models import Course, Chapter, ChapterContent, CourseFavorite, ChapterRead, CourseMaterial
from .serializers import (
CourseListSerializer, CourseDetailSerializer,
CourseCreateUpdateSerializer, CourseManageSerializer,
ChapterSerializer, ChapterContentSerializer, MaterialSerializer,
)
class CourseListCreateView(generics.ListCreateAPIView):
parser_classes = [JSONParser, MultiPartParser, FormParser]
def get_permissions(self):
if self.request.method == 'GET':
return [AllowAny()]
return [IsAuthenticated()]
def get_serializer_class(self):
if self.request.method == 'POST':
return CourseCreateUpdateSerializer
return CourseListSerializer
def get_queryset(self):
qs = Course.objects.filter(status='published')
category = self.request.query_params.get('category')
if category and category != 'all':
qs = qs.filter(category=category)
level = self.request.query_params.get('level')
if level and level != 'all':
qs = qs.filter(level=level)
sort = self.request.query_params.get('sort', '')
if sort == 'created_at':
qs = qs.order_by('created_at')
elif sort == '-created_at':
qs = qs.order_by('-created_at')
elif sort == 'updated_at':
qs = qs.order_by('-updated_at')
elif sort == '-updated_at':
qs = qs.order_by('-updated_at')
elif sort == 'sort_order':
qs = qs.order_by('-sort_order')
elif sort == '-sort_order':
qs = qs.order_by('sort_order')
elif sort == 'popularity':
qs = qs.annotate(favorites_count=Count('course_favorites')).order_by('favorites_count')
elif sort == '-popularity':
qs = qs.annotate(favorites_count=Count('course_favorites')).order_by('-favorites_count')
return qs
@swagger_auto_schema(
tags=['学习'],
operation_summary='获取课程列表',
operation_description='获取已发布课程列表,支持按分类筛选和排序,登录用户可查看收藏状态',
manual_parameters=[
openapi.Parameter('category', openapi.IN_QUERY, description='课程分类筛选(frontend/backend/tools/devops/security/basic)', type=openapi.TYPE_STRING),
openapi.Parameter('level', openapi.IN_QUERY, description='课程难度筛选(beginner=入门/advanced=进阶)', type=openapi.TYPE_STRING),
openapi.Parameter('sort', openapi.IN_QUERY, description='排序方式:created_at(正序)、-created_at(倒序,默认最新)、-updated_at(最近更新)、-popularity(最热,按收藏数)', type=openapi.TYPE_STRING),
],
responses={200: success_response},
)
def list(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
data = serializer.data
if request.user.is_authenticated:
favorite_course_ids = set(
CourseFavorite.objects.filter(user=request.user).values_list('course_id', flat=True)
)
for item in data:
item['is_favorited'] = item['id'] in favorite_course_ids
item['favorites_count'] = CourseFavorite.objects.filter(course_id=item['id']).count()
else:
for item in data:
item['is_favorited'] = False
item['favorites_count'] = CourseFavorite.objects.filter(course_id=item['id']).count()
return create_standardized_response(data=data, code=ResponseCode.SUCCESS)
@swagger_auto_schema(
tags=['学习'],
operation_summary='创建课程',
operation_description='创建新课程,需要登录',
request_body=CourseCreateUpdateSerializer,
responses={
201: success_response,
400: error_response,
401: unauthorized_response,
},
)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
serializer.save()
return create_standardized_response(
data=serializer.data,
code=ResponseCode.SUCCESS,
message='课程创建成功',
status_code=status.HTTP_201_CREATED
)
return create_standardized_error_response(
data=serializer.errors,
code=ResponseCode.VALIDATION_ERROR,
message='提交失败',
status_code=status.HTTP_400_BAD_REQUEST
)
class CourseDetailView(APIView):
parser_classes = [JSONParser, MultiPartParser, FormParser]
permission_classes = [AllowAny]
@swagger_auto_schema(
tags=['学习'],
operation_summary='获取课程详情',
operation_description='根据课程ID获取课程详细信息,包含章节列表',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='课程ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={
200: success_response,
404: not_found_response,
},
)
def get(self, request, pk):
course = get_object_or_404(Course, pk=pk)
serializer = CourseDetailSerializer(course, context={'request': request})
data = serializer.data
if request.user.is_authenticated:
data['is_favorited'] = CourseFavorite.objects.filter(
user=request.user, course=course
).exists()
else:
data['is_favorited'] = False
data['favorites_count'] = CourseFavorite.objects.filter(course=course).count()
return create_standardized_response(data=data, code=ResponseCode.SUCCESS)
@swagger_auto_schema(
tags=['学习'],
operation_summary='更新课程',
operation_description='更新课程信息,仅课程作者可操作',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='课程ID', type=openapi.TYPE_INTEGER, required=True),
],
request_body=CourseCreateUpdateSerializer,
responses={
200: success_response,
400: error_response,
401: unauthorized_response,
403: error_response,
404: not_found_response,
},
)
def put(self, request, pk):
course = get_object_or_404(Course, pk=pk)
if course.author != request.user:
return create_standardized_error_response(
code=ResponseCode.VALIDATION_ERROR,
message='无权操作',
status_code=status.HTTP_403_FORBIDDEN
)
serializer = CourseCreateUpdateSerializer(course, data=request.data, partial=True, context={'request': request})
if serializer.is_valid():
serializer.save()
return create_standardized_response(data=serializer.data, code=ResponseCode.SUCCESS, message='课程更新成功')
return create_standardized_error_response(
data=serializer.errors,
code=ResponseCode.VALIDATION_ERROR,
status_code=status.HTTP_400_BAD_REQUEST
)
@swagger_auto_schema(
tags=['学习'],
operation_summary='删除课程',
operation_description='删除课程,仅课程作者可操作',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='课程ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={
204: success_response,
401: unauthorized_response,
403: error_response,
404: not_found_response,
},
)
def delete(self, request, pk):
course = get_object_or_404(Course, pk=pk)
if course.author != request.user:
return create_standardized_error_response(
code=ResponseCode.VALIDATION_ERROR,
message='无权操作',
status_code=status.HTTP_403_FORBIDDEN
)
course.delete()
return create_standardized_response(code=ResponseCode.SUCCESS, message='课程删除成功', status_code=status.HTTP_204_NO_CONTENT)
class ChapterListCreateView(generics.ListCreateAPIView):
parser_classes = [JSONParser, MultiPartParser]
def get_permissions(self):
if self.request.method == 'GET':
return [AllowAny()]
return [IsAuthenticated()]
serializer_class = ChapterSerializer
def get_queryset(self):
course_id = self.kwargs['course_id']
return Chapter.objects.filter(course_id=course_id)
@swagger_auto_schema(
tags=['学习'],
operation_summary='获取章节列表',
operation_description='获取指定课程下的所有章节',
manual_parameters=[
openapi.Parameter('course_id', openapi.IN_PATH, description='课程ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={200: success_response, 404: not_found_response},
)
def list(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
return create_standardized_response(data=serializer.data, code=ResponseCode.SUCCESS)
@swagger_auto_schema(
tags=['学习'],
operation_summary='创建章节',
operation_description='为指定课程创建新章节,需要登录',
manual_parameters=[
openapi.Parameter('course_id', openapi.IN_PATH, description='课程ID', type=openapi.TYPE_INTEGER, required=True),
],
request_body=ChapterSerializer,
responses={
201: success_response,
400: error_response,
401: unauthorized_response,
404: not_found_response,
},
)
def create(self, request, *args, **kwargs):
course_id = self.kwargs['course_id']
course = get_object_or_404(Course, pk=course_id)
serializer = self.get_serializer(data=request.data)
if serializer.is_valid():
serializer.save(course=course)
return create_standardized_response(
data=serializer.data,
code=ResponseCode.SUCCESS,
message='章节创建成功',
status_code=status.HTTP_201_CREATED
)
return create_standardized_error_response(
data=serializer.errors,
code=ResponseCode.VALIDATION_ERROR,
message='提交失败',
status_code=status.HTTP_400_BAD_REQUEST
)
class ChapterDetailView(APIView):
parser_classes = [JSONParser, MultiPartParser, FormParser]
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['学习'],
operation_summary='获取章节详情',
operation_description='根据章节ID获取章节详细信息,需要登录',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='章节ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={
200: success_response,
401: unauthorized_response,
404: not_found_response,
},
)
def get(self, request, pk):
chapter = get_object_or_404(Chapter, pk=pk)
serializer = ChapterSerializer(chapter)
return create_standardized_response(data=serializer.data, code=ResponseCode.SUCCESS)
@swagger_auto_schema(
tags=['学习'],
operation_summary='更新章节',
operation_description='更新章节信息,需要登录',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='章节ID', type=openapi.TYPE_INTEGER, required=True),
],
request_body=ChapterSerializer,
responses={
200: success_response,
400: error_response,
401: unauthorized_response,
404: not_found_response,
},
)
def put(self, request, pk):
chapter = get_object_or_404(Chapter, pk=pk)
serializer = ChapterSerializer(chapter, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return create_standardized_response(data=serializer.data, code=ResponseCode.SUCCESS, message='章节更新成功')
return create_standardized_error_response(
data=serializer.errors,
code=ResponseCode.VALIDATION_ERROR,
status_code=status.HTTP_400_BAD_REQUEST
)
@swagger_auto_schema(
tags=['学习'],
operation_summary='删除章节',
operation_description='删除章节,需要登录',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='章节ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={
204: success_response,
401: unauthorized_response,
404: not_found_response,
},
)
def delete(self, request, pk):
chapter = get_object_or_404(Chapter, pk=pk)
chapter.delete()
return create_standardized_response(code=ResponseCode.SUCCESS, message='章节删除成功', status_code=status.HTTP_204_NO_CONTENT)
class ChapterContentView(APIView):
parser_classes = [JSONParser, MultiPartParser, FormParser]
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['学习'],
operation_summary='获取章节内容',
operation_description='获取指定章节的Markdown和HTML内容,需要登录',
manual_parameters=[
openapi.Parameter('chapter_id', openapi.IN_PATH, description='章节ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={
200: success_response,
401: unauthorized_response,
404: not_found_response,
},
)
def get(self, request, chapter_id):
chapter = get_object_or_404(Chapter, pk=chapter_id)
content, created = ChapterContent.objects.get_or_create(
chapter=chapter,
defaults={'content_md': '', 'content_html': ''}
)
serializer = ChapterContentSerializer(content)
return create_standardized_response(data=serializer.data, code=ResponseCode.SUCCESS)
@swagger_auto_schema(
tags=['学习'],
operation_summary='更新章节内容',
operation_description='保存或更新章节的Markdown和HTML内容,需要登录',
manual_parameters=[
openapi.Parameter('chapter_id', openapi.IN_PATH, description='章节ID', type=openapi.TYPE_INTEGER, required=True),
],
request_body=ChapterContentSerializer,
responses={
200: success_response,
400: error_response,
401: unauthorized_response,
404: not_found_response,
},
)
def put(self, request, chapter_id):
chapter = get_object_or_404(Chapter, pk=chapter_id)
content, created = ChapterContent.objects.get_or_create(
chapter=chapter,
defaults={'content_md': '', 'content_html': ''}
)
serializer = ChapterContentSerializer(content, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return create_standardized_response(data=serializer.data, code=ResponseCode.SUCCESS, message='内容保存成功')
return create_standardized_error_response(
data=serializer.errors,
code=ResponseCode.VALIDATION_ERROR,
status_code=status.HTTP_400_BAD_REQUEST
)
class MyCourseListView(generics.ListAPIView):
permission_classes = [IsAuthenticated]
serializer_class = CourseManageSerializer
parser_classes = [JSONParser]
def get_queryset(self):
qs = Course.objects.filter(author=self.request.user)
st = self.request.query_params.get('status')
if st:
qs = qs.filter(status=st)
return qs.order_by('-updated_at')
@swagger_auto_schema(
tags=['学习'],
operation_summary='获取我的课程列表',
operation_description='获取当前登录用户创建的课程列表,支持按状态筛选',
manual_parameters=[
openapi.Parameter('status', openapi.IN_QUERY, description='课程状态筛选(draft/published)', type=openapi.TYPE_STRING),
],
responses={
200: success_response,
401: unauthorized_response,
},
)
def list(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
return create_standardized_response(data=serializer.data, code=ResponseCode.SUCCESS)
class MyCourseBatchView(APIView):
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
@swagger_auto_schema(
tags=['学习'],
operation_summary='批量操作课程',
operation_description='批量发布、撤回草稿或删除课程,需要登录',
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['ids', 'action'],
properties={
'ids': openapi.Schema(type=openapi.TYPE_ARRAY, items=openapi.Schema(type=openapi.TYPE_INTEGER), description='课程ID列表'),
'action': openapi.Schema(type=openapi.TYPE_STRING, description='操作类型', enum=['publish', 'draft', 'delete']),
},
),
responses={
200: success_response,
400: error_response,
401: unauthorized_response,
},
)
def post(self, request):
ids = request.data.get('ids', [])
action = request.data.get('action', '')
if not ids or action not in ('publish', 'draft', 'delete'):
return create_standardized_error_response(
code=ResponseCode.VALIDATION_ERROR,
message='参数错误',
status_code=status.HTTP_400_BAD_REQUEST
)
qs = Course.objects.filter(id__in=ids, author=request.user)
if action == 'delete':
count = qs.delete()[0]
elif action == 'publish':
count = qs.update(status='published')
elif action == 'draft':
count = qs.update(status='draft')
return create_standardized_response(
data={'affected': count},
code=ResponseCode.SUCCESS,
message=f'批量操作成功,影响 {count} 个课程'
)
class CDNStaticFileView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema(
tags=['学习'],
operation_summary='获取章节静态文件',
operation_description='获取课程章节的Markdown静态文件,支持CDN缓存',
manual_parameters=[
openapi.Parameter('course_id', openapi.IN_PATH, description='课程ID', type=openapi.TYPE_INTEGER, required=True),
openapi.Parameter('chapter_id', openapi.IN_PATH, description='章节ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={
200: openapi.Response(description='Markdown文件内容', schema=openapi.Schema(type=openapi.TYPE_FILE)),
404: not_found_response,
},
)
def get(self, request, course_id, chapter_id):
file_path = os.path.join(
settings.MEDIA_ROOT,
'learn', 'courses', str(course_id), 'chapters', f'{chapter_id}.md'
)
if not os.path.exists(file_path):
raise Http404
fh = open(file_path, "rb")
try:
response = FileResponse(fh, content_type="text/markdown; charset=utf-8")
response["Cache-Control"] = "max-age=86400"
return response
except Exception:
fh.close()
raise
class CourseFavoriteToggleView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['学习'],
operation_summary='切换课程收藏状态',
operation_description='收藏或取消收藏课程,需要登录',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='课程ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={
200: success_response,
401: unauthorized_response,
404: not_found_response,
},
)
def post(self, request, pk):
course = Course.objects.filter(pk=pk, status='published').first()
if not course:
return create_standardized_error_response(
code=ResponseCode.VALIDATION_ERROR,
message='课程不存在',
status_code=status.HTTP_404_NOT_FOUND
)
fav, created = CourseFavorite.objects.get_or_create(user=request.user, course=course)
if not created:
fav.delete()
count = CourseFavorite.objects.filter(course=course).count()
return create_standardized_response(
data={'favorited': False, 'favorites_count': count}
)
count = CourseFavorite.objects.filter(course=course).count()
return create_standardized_response(
data={'favorited': True, 'favorites_count': count}
)
class ChapterMarkCompletedView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['学习'],
operation_summary='标记章节已读/已完成',
operation_description='将指定章节标记为已读或取消标记,需要登录',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='章节ID', type=openapi.TYPE_INTEGER, required=True),
],
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'completed': openapi.Schema(type=openapi.TYPE_BOOLEAN, description='是否已完成,默认true', default=True),
},
),
responses={
200: success_response,
401: unauthorized_response,
404: not_found_response,
},
)
def post(self, request, pk):
chapter = Chapter.objects.filter(pk=pk).select_related('course').first()
if not chapter:
return create_standardized_error_response(
code=ResponseCode.VALIDATION_ERROR,
message='章节不存在',
status_code=status.HTTP_404_NOT_FOUND
)
completed = request.data.get('completed', True)
if not isinstance(completed, bool):
completed = str(completed).lower() in ('true', '1', 'yes')
read_record, created = ChapterRead.objects.get_or_create(
user=request.user,
chapter=chapter,
defaults={
'course': chapter.course,
'completed': completed,
'completed_at': timezone.now() if completed else None,
}
)
if not created:
read_record.completed = completed
read_record.completed_at = timezone.now() if completed else None
read_record.save(update_fields=['completed', 'completed_at', 'updated_at'])
total_chapters = chapter.course.chapters.count()
completed_chapters = ChapterRead.objects.filter(
user=request.user, course=chapter.course, completed=True
).count()
progress = round((completed_chapters / total_chapters) * 100) if total_chapters > 0 else 0
return create_standardized_response(
data={
'chapter_id': chapter.id,
'completed': completed,
'completed_chapters': completed_chapters,
'total_chapters': total_chapters,
'progress': progress,
}
)
class CourseProgressView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['学习'],
operation_summary='获取课程学习进度',
operation_description='获取当前用户在该课程下的所有章节完成状态,需要登录',
manual_parameters=[
openapi.Parameter('course_id', openapi.IN_PATH, description='课程ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={
200: success_response,
401: unauthorized_response,
404: not_found_response,
},
)
def get(self, request, course_id):
course = Course.objects.filter(pk=course_id, status='published').first()
if not course:
return create_standardized_error_response(
code=ResponseCode.VALIDATION_ERROR,
message='课程不存在',
status_code=status.HTTP_404_NOT_FOUND
)
completed_ids = set(
ChapterRead.objects.filter(
user=request.user, course=course, completed=True
).values_list('chapter_id', flat=True)
)
total_chapters = course.chapters.count()
completed_count = len(completed_ids)
progress = round((completed_count / total_chapters) * 100) if total_chapters > 0 else 0
return create_standardized_response(
data={
'course_id': course.id,
'completed_chapter_ids': list(completed_ids),
'completed_chapters': completed_count,
'total_chapters': total_chapters,
'progress': progress,
}
)
class MaterialListView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['学习'],
operation_summary='获取章节资料列表',
operation_description='获取指定章节的可下载资料列表',
manual_parameters=[
openapi.Parameter('chapter_id', openapi.IN_QUERY, description='章节ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={200: success_response},
)
def get(self, request):
chapter_id = request.query_params.get('chapter_id')
if not chapter_id:
return create_standardized_error_response(
code=ResponseCode.PARAMETER_ERROR,
message='缺少章节ID',
status_code=status.HTTP_400_BAD_REQUEST
)
materials = CourseMaterial.objects.filter(chapter_id=chapter_id)
serializer = MaterialSerializer(materials, many=True, context={'request': request})
return create_standardized_response(data=serializer.data, code=ResponseCode.SUCCESS)
class MaterialDownloadView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['学习'],
operation_summary='下载课程资料',
operation_description='下载指定ID的课程资料文件,并增加下载次数统计',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='资料ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={200: openapi.Response(description='文件流'), 404: not_found_response},
)
def get(self, request, pk):
material = CourseMaterial.objects.filter(pk=pk).first()
if not material or not material.file:
raise Http404
material.download_count += 1
material.save(update_fields=['download_count'])
response = FileResponse(material.file.open('rb'), content_type='application/octet-stream')
response['Content-Disposition'] = f'attachment; filename="{material.title}"'
return response
class ChapterVideoDownloadView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['学习'],
operation_summary='下载章节视频',
operation_description='下载指定章节的本地视频文件',
manual_parameters=[
openapi.Parameter('pk', openapi.IN_PATH, description='章节ID', type=openapi.TYPE_INTEGER, required=True),
],
responses={200: openapi.Response(description='文件流'), 404: not_found_response},
)
def get(self, request, pk):
chapter = Chapter.objects.filter(pk=pk).first()
if not chapter or not chapter.video_local:
raise Http404
response = FileResponse(chapter.video_local.open('rb'), content_type='application/octet-stream')
response['Content-Disposition'] = f'attachment; filename="{chapter.title}.mp4"'
return response
class MyProgressView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['学习'],
operation_summary='获取当前用户学习进度',
operation_description='获取当前用户所有课程的学习进度列表,需要登录',
responses={200: success_response, 401: unauthorized_response},
)
def get(self, request):
user = request.user
reads = ChapterRead.objects.filter(user=user, completed=True).select_related('course', 'chapter')
course_map = {}
for read in reads:
course_id = read.course.id
if course_id not in course_map:
course_map[course_id] = {
'id': course_id,
'title': read.course.title,
'cover_image': read.course.cover_image.url if read.course.cover_image else '',
'completed_chapters': 0,
'total_chapters': read.course.chapters.count(),
'last_studied_at': read.completed_at,
}
course_map[course_id]['completed_chapters'] += 1
if read.completed_at and (
not course_map[course_id]['last_studied_at'] or
read.completed_at > course_map[course_id]['last_studied_at']
):
course_map[course_id]['last_studied_at'] = read.completed_at
courses = []
for c in course_map.values():
total = c['total_chapters']
completed = c['completed_chapters']
c['progress'] = round((completed / total) * 100) if total > 0 else 0
c['last_studied_at'] = c['last_studied_at'].strftime('%Y-%m-%d %H:%M:%S') if c['last_studied_at'] else None
courses.append(c)
courses.sort(key=lambda x: x['last_studied_at'] or '', reverse=True)
total_completed = sum(1 for c in courses if c['progress'] >= 100)
return create_standardized_response(
data={
'courses': courses,
'total_courses': len(courses),
'total_completed': total_completed,
}
)