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 adrf import generics from adrf.generics import aget_object_or_404 from adrf.mixins import get_data from adrf.views import APIView from asgiref.sync import sync_to_async from rest_framework import status from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.parsers import JSONParser, MultiPartParser, FormParser 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 async def get(self, request, *args, **kwargs): return await self.list(request, *args, **kwargs) async def post(self, request, *args, **kwargs): return await self.create(request, *args, **kwargs) @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}, ) async def list(self, request, *args, **kwargs): queryset = self.get_queryset() serializer = self.get_serializer(queryset, many=True) data = await get_data(serializer) if request.user.is_authenticated: favorite_course_ids = set([ v async for v in 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'] = await CourseFavorite.objects.filter(course_id=item['id']).acount() else: for item in data: item['is_favorited'] = False item['favorites_count'] = await CourseFavorite.objects.filter(course_id=item['id']).acount() 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, }, ) async def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) if serializer.is_valid(): await serializer.asave() data = await get_data(serializer) return create_standardized_response( data=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, }, ) async def get(self, request, pk): course = await aget_object_or_404(Course, pk=pk) serializer = CourseDetailSerializer(course, context={'request': request}) data = await get_data(serializer) if request.user.is_authenticated: data['is_favorited'] = await CourseFavorite.objects.filter( user=request.user, course=course ).aexists() else: data['is_favorited'] = False data['favorites_count'] = await CourseFavorite.objects.filter(course=course).acount() 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, }, ) async def put(self, request, pk): course = await aget_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(): await serializer.asave() data = await get_data(serializer) return create_standardized_response(data=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, }, ) async def delete(self, request, pk): course = await aget_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 ) await course.adelete() 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) async def get(self, request, *args, **kwargs): return await self.list(request, *args, **kwargs) async def post(self, request, *args, **kwargs): return await self.create(request, *args, **kwargs) @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}, ) async def list(self, request, *args, **kwargs): queryset = self.get_queryset() serializer = self.get_serializer(queryset, many=True) data = await get_data(serializer) return create_standardized_response(data=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, }, ) async def create(self, request, *args, **kwargs): course_id = self.kwargs['course_id'] course = await aget_object_or_404(Course, pk=course_id) serializer = self.get_serializer(data=request.data) if serializer.is_valid(): await sync_to_async(serializer.save)(course=course) data = await get_data(serializer) return create_standardized_response( data=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, }, ) async def get(self, request, pk): chapter = await aget_object_or_404(Chapter, pk=pk) serializer = ChapterSerializer(chapter) data = await get_data(serializer) 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=ChapterSerializer, responses={ 200: success_response, 400: error_response, 401: unauthorized_response, 404: not_found_response, }, ) async def put(self, request, pk): chapter = await aget_object_or_404(Chapter, pk=pk) serializer = ChapterSerializer(chapter, data=request.data, partial=True) if serializer.is_valid(): await serializer.asave() data = await get_data(serializer) return create_standardized_response(data=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, }, ) async def delete(self, request, pk): chapter = await aget_object_or_404(Chapter, pk=pk) await chapter.adelete() 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, }, ) async def get(self, request, chapter_id): chapter = await aget_object_or_404(Chapter, pk=chapter_id) content, created = await ChapterContent.objects.aget_or_create( chapter=chapter, defaults={'content_md': '', 'content_html': ''} ) serializer = ChapterContentSerializer(content) data = await get_data(serializer) return create_standardized_response(data=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, }, ) async def put(self, request, chapter_id): chapter = await aget_object_or_404(Chapter, pk=chapter_id) content, created = await ChapterContent.objects.aget_or_create( chapter=chapter, defaults={'content_md': '', 'content_html': ''} ) serializer = ChapterContentSerializer(content, data=request.data, partial=True) if serializer.is_valid(): await serializer.asave() data = await get_data(serializer) return create_standardized_response(data=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') async def get(self, request, *args, **kwargs): return await self.list(request, *args, **kwargs) @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, }, ) async def list(self, request, *args, **kwargs): queryset = self.get_queryset() serializer = self.get_serializer(queryset, many=True) data = await get_data(serializer) return create_standardized_response(data=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, }, ) async 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 = (await qs.adelete())[0] elif action == 'publish': count = await qs.aupdate(status='published') elif action == 'draft': count = await qs.aupdate(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, }, ) async 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' ) def _open_file(): 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 # TODO: aiohttp 化(当前用 sync_to_async 兜底避免阻塞事件循环) return await sync_to_async(_open_file)() 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, }, ) async def post(self, request, pk): course = await Course.objects.filter(pk=pk, status='published').afirst() if not course: return create_standardized_error_response( code=ResponseCode.VALIDATION_ERROR, message='课程不存在', status_code=status.HTTP_404_NOT_FOUND ) fav, created = await CourseFavorite.objects.aget_or_create(user=request.user, course=course) if not created: await fav.adelete() count = await CourseFavorite.objects.filter(course=course).acount() return create_standardized_response( data={'favorited': False, 'favorites_count': count} ) count = await CourseFavorite.objects.filter(course=course).acount() 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, }, ) async def post(self, request, pk): chapter = await Chapter.objects.filter(pk=pk).select_related('course').afirst() 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 = await ChapterRead.objects.aget_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 await read_record.asave(update_fields=['completed', 'completed_at', 'updated_at']) total_chapters = await chapter.course.chapters.acount() completed_chapters = await ChapterRead.objects.filter( user=request.user, course=chapter.course, completed=True ).acount() 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, }, ) async def get(self, request, course_id): course = await Course.objects.filter(pk=course_id, status='published').afirst() if not course: return create_standardized_error_response( code=ResponseCode.VALIDATION_ERROR, message='课程不存在', status_code=status.HTTP_404_NOT_FOUND ) completed_ids = set([ v async for v in ChapterRead.objects.filter( user=request.user, course=course, completed=True ).values_list('chapter_id', flat=True) ]) total_chapters = await course.chapters.acount() 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}, ) async 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}) data = await get_data(serializer) return create_standardized_response(data=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}, ) async def get(self, request, pk): material = await CourseMaterial.objects.filter(pk=pk).afirst() if not material or not material.file: raise Http404 material.download_count += 1 await material.asave(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}, ) async def get(self, request, pk): chapter = await Chapter.objects.filter(pk=pk).afirst() 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}, ) async def get(self, request): user = request.user reads = ChapterRead.objects.filter(user=user, completed=True).select_related('course', 'chapter') course_map = {} async 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': await read.course.chapters.acount(), '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, } )