You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

420 lines
15 KiB

from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models import Count, Q, F
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.exceptions import NotFound
from rest_framework.filters import SearchFilter
from rest_framework.generics import GenericAPIView, ListAPIView, RetrieveAPIView
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from apps.course.serializers import (
CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer,
CourseAttachmentSerializer, CourseGlossarySerializer, MyCourseListSerializer,
OnlineClassTokenCreateSerializer, OnlineClassTokenVerifySerializer
)
from apps.course.models import Course, CourseCategory, CourseAttachment, CourseGlossary, Participant
from apps.course.doc import *
from apps.account.serializers import UserProfileSerializer
from utils.exceptions import AppAPIException
from utils.redis import OnlineClassTokenManager
UserModel = get_user_model()
class CourseCategoryAPIView(ListAPIView):
queryset = CourseCategory.objects.all()
serializer_class = CourseCategorySerializer
@swagger_auto_schema(
operation_description=doc_course_category(),
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
class CourseListAPIView(ListAPIView):
serializer_class = CourseListSerializer
filter_backends = [SearchFilter]
search_fields = ['title', 'category__name', 'professor__fullname']
@swagger_auto_schema(
operation_description=doc_course_list(),
manual_parameters=[
openapi.Parameter(
'search', openapi.IN_QUERY,
description="Search by course title, category name, or professor's full name",
type=openapi.TYPE_STRING,
),
openapi.Parameter(
'category_slug', openapi.IN_QUERY,
description="Category of the Course",
type=openapi.TYPE_STRING,
# enum=[category.slug for category in CourseCategory.objects.all()]
),
openapi.Parameter(
'status', openapi.IN_QUERY,
type=openapi.TYPE_STRING,
description="""Status =>
Upcoming (visible but registration not allowed)---Предстоящие
Registering (registration is open)---регистрация
Ongoing (course has started, registration closed)---Впроцессе
Finished (course has ended)---закончился
""",
enum=[status for status in ['upcoming', 'registering', 'ongoing', 'finished']]
),
openapi.Parameter(
'is_free', openapi.IN_QUERY,
description="Ценообразование is_free <bool>",
type=openapi.TYPE_BOOLEAN,
),
openapi.Parameter(
'is_online', openapi.IN_QUERY,
description="Статус участия is_online <bool>",
type=openapi.TYPE_BOOLEAN,
),
])
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
"""
Optimized queryset with select_related for ForeignKey relationships and filtering
"""
queryset = Course.objects.select_related(
'category',
'professor'
).exclude(status=Course.StatusChoices.INACTIVE)
request = self.request
filters = request.query_params
# Handle category_slug with multiple values separated by commas
if category_slugs := filters.get('category_slug'):
category_slugs_list = category_slugs.split(',')
queryset = queryset.filter(category__slug__in=category_slugs_list)
# Handle status with multiple values separated by commas
if statuses := filters.get('status'):
statuses_list = statuses.split(',')
queryset = queryset.filter(status__in=statuses_list)
if is_free := filters.get('is_free'):
is_free = is_free.lower() == 'true'
queryset = queryset.filter(
Q(is_free=is_free) | Q(price=0) if is_free else Q(is_free=False, price__gt=0)
)
if is_online := filters.get('is_online'):
is_online = is_online.lower() == 'true'
queryset = queryset.filter(is_online=is_online)
return queryset
class CourseDetailAPIView(RetrieveAPIView):
serializer_class = CourseDetailSerializer
lookup_field = "slug"
def get_queryset(self):
"""
Optimized queryset with select_related and prefetch_related for all relationships
"""
return Course.objects.select_related(
'category',
'professor'
).prefetch_related(
'lessons__lesson',
'lessons__completions',
'attachments__attachment',
'glossaries__glossary',
'participants__student',
'room_messages'
)
@swagger_auto_schema(
operation_description=doc_course_detail(),
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
class MyCourseListAPIView(ListAPIView):
serializer_class = MyCourseListSerializer
permission_classes = [IsAuthenticated]
@swagger_auto_schema(manual_parameters=[
openapi.Parameter(
'completed', openapi.IN_QUERY,
description="мои курсы completed <bool> true",
type=openapi.TYPE_BOOLEAN,
),
openapi.Parameter(
'certificate', openapi.IN_QUERY,
type=openapi.TYPE_BOOLEAN,
),
],
operation_description=doc_courses_my_courses(),
operation_summary="Home",
)
def get(self, request, *args, **kwargs):
print(f'--> my-course-> {request}/ {kwargs}')
return super().get(request, *args, **kwargs)
def get_queryset(self):
"""
Optimized queryset for user's courses with select_related and prefetch_related
"""
queryset = Course.objects.select_related(
'category',
'professor'
).prefetch_related(
'lessons__lesson',
'lessons__completions',
'participants__student'
).exclude(status=Course.StatusChoices.INACTIVE)
request = self.request
filters = request.query_params
student = self.request.user
qs = queryset.filter(participants__student=student)
completed_only = filters.get('completed', '').lower() == 'true'
if completed_only == True:
# نمایش دوره‌هایی که همه درس‌هایشان توسط کاربر تکمیل شده‌اند
qs = qs.annotate(
total_lessons=Count('lessons', distinct=True),
completed_lessons=Count(
'lessons__completions',
filter=Q(lessons__completions__student=student),
distinct=True
)
).filter(total_lessons=F('completed_lessons'))
elif completed_only == False:
# نمایش دوره‌هایی که همه درس‌هایشان تکمیل نشده‌اند
qs = qs.annotate(
total_lessons=Count('lessons', distinct=True),
completed_lessons=Count(
'lessons__completions',
filter=Q(lessons__completions__student=student),
distinct=True
)
).filter(total_lessons__gt=F('completed_lessons'))
if 'completed' not in filters:
certificate = filters.get('certificate', '').lower() == 'true'
if certificate:
qs = qs.exclude(
course_certificates__student=student,
course_certificates__status__in=['pending', 'approved']
)
return qs
class AttachmentListAPIView(ListAPIView):
serializer_class = CourseAttachmentSerializer
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(
'slug', openapi.IN_PATH,
description="Slug of the Course",
type=openapi.TYPE_STRING,
required=True
)
],
operation_description="Retrieve a list of attachments for a given course by its slug."
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
"""
Optimized queryset with select_related for attachment relationship
"""
course_slug = self.kwargs.get('slug')
try:
course = Course.objects.get(slug=course_slug)
except Course.DoesNotExist:
raise NotFound("Course not found")
return CourseAttachment.objects.select_related(
'course',
'attachment'
).filter(course=course)
class GlossaryListAPIView(ListAPIView):
serializer_class = CourseGlossarySerializer
filter_backends = [SearchFilter]
search_fields = ['glossary__title', 'glossary__description']
def get_queryset(self):
"""
Optimized queryset with select_related for glossary relationship
"""
course_slug = self.kwargs.get('slug')
try:
course = Course.objects.get(slug=course_slug)
except Course.DoesNotExist:
raise NotFound("Course not found")
return CourseGlossary.objects.select_related(
'course',
'glossary'
).filter(course=course)
class CourseOnlineClassTokenAPIView(GenericAPIView):
permission_classes = [IsAuthenticated]
serializer_class = OnlineClassTokenCreateSerializer
@swagger_auto_schema(
operation_description="Generate a temporary entry token for an online class.",
request_body=OnlineClassTokenCreateSerializer,
responses={
status.HTTP_201_CREATED: openapi.Response(
description="Token generated successfully.",
examples={
"application/json": {
"token": "<temporary-token>",
"url": "https://frontend.example.com?token=<temporary-token>",
"expires_in": 300,
}
}
)
}
)
def post(self, request, pk, *args, **kwargs):
serializer = self.get_serializer(data=request.data or {})
serializer.is_valid(raise_exception=True)
course = get_object_or_404(Course, pk=pk)
if not course.is_online:
raise AppAPIException({'message': "Course is not marked as online."}, status_code=status.HTTP_400_BAD_REQUEST)
if not self._user_has_access(request.user, course):
raise AppAPIException({'message': "You do not have access to this course."}, status_code=status.HTTP_403_FORBIDDEN)
manager = OnlineClassTokenManager()
user_token, _ = Token.objects.get_or_create(user=request.user)
identifier = f"{request.user.id}:{user_token.key[:8]}"
token = manager.generate_token(course_id=course.id, user_identifier=identifier)
manager.store_token(token, {
'course_id': course.id,
'user_id': request.user.id,
'user_token': user_token.key,
'extra': {
'professor_in_class': False,
},
})
redirect_path = serializer.validated_data.get('redirect_path')
base_url = self._build_base_url(redirect_path)
entry_url = manager.build_entry_url(token, base_url=base_url)
return Response({
'token': token,
'url': entry_url,
'expires_in': getattr(settings, 'ONLINE_CLASS_TOKEN_TTL', 300),
}, status=status.HTTP_201_CREATED)
def _build_base_url(self, redirect_path=None) -> str:
domain = getattr(settings, 'ONLINE_CLASS_FRONTEND_DOMAIN', getattr(settings, 'SITE_DOMAIN', '')).rstrip('/')
if redirect_path:
sanitized = redirect_path.strip('/')
return f"{domain}/{sanitized}" if domain else f"/{sanitized}"
return domain
@staticmethod
def _user_has_access(user, course: Course) -> bool:
if user.is_staff or course.professor_id == user.id:
return True
return Participant.objects.filter(course=course, student=user).exists()
class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
permission_classes = [AllowAny]
serializer_class = OnlineClassTokenVerifySerializer
@swagger_auto_schema(
operation_description="Validate an online class entry token and return course/user data.",
request_body=OnlineClassTokenVerifySerializer,
responses={
status.HTTP_200_OK: openapi.Response(
description="Token validated.",
examples={
"application/json": {
"course": {"id": 1, "title": "Sample Course"},
"user": {"id": 10, "fullname": "John Doe"},
"metadata": {
"status": "ongoing",
"has_started": True,
"professor_in_class": False,
"validated_at": "2024-01-01T10:00:00Z"
}
}
}
)
}
)
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
token_value = serializer.validated_data['token']
manager = OnlineClassTokenManager()
payload = manager.get_payload(token_value)
course_id = payload.get('course_id')
user_id = payload.get('user_id')
if not course_id or not user_id:
raise AppAPIException({'message': 'Token payload is invalid.'}, status_code=status.HTTP_400_BAD_REQUEST)
detail_view = CourseDetailAPIView()
queryset = detail_view.get_queryset()
course = get_object_or_404(queryset, pk=course_id)
user = get_object_or_404(UserModel.objects.all(), pk=user_id)
course_data = CourseDetailSerializer(course, context={'request': request}).data
user_data = UserProfileSerializer(user, context={'request': request}).data
metadata = self._build_metadata(course, payload)
return Response({
'course': course_data,
'user': user_data,
'metadata': metadata,
}, status=status.HTTP_200_OK)
def _build_metadata(self, course: Course, payload: dict) -> dict:
status_value = course.status
has_started = status_value in [Course.StatusChoices.ONGOING, Course.StatusChoices.FINISHED]
timing_data = course.timing if isinstance(course.timing, dict) else {}
return {
'status': status_value,
'is_online': course.is_online,
'has_started': has_started,
'has_finished': status_value == Course.StatusChoices.FINISHED,
'professor_in_class': payload.get('extra', {}).get('professor_in_class', False),
'scheduled_times': timing_data,
'generated_at': payload.get('generated_at'),
'validated_at': timezone.now().isoformat(),
}