775 lines
31 KiB
Python
775 lines
31 KiB
Python
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,
|
||
}
|
||
) |