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.
526 lines
19 KiB
526 lines
19 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,
|
|
CourseAttachment,
|
|
CourseCategory,
|
|
CourseGlossary,
|
|
CourseLiveSession,
|
|
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)
|
|
|
|
redirect_path = serializer.validated_data.get('redirect_path')
|
|
base_url, path_value = self._build_base_components(request, redirect_path)
|
|
|
|
manager.store_token(token, {
|
|
'course_id': course.id,
|
|
'user_id': request.user.id,
|
|
'user_token': user_token.key,
|
|
'redirect_path': path_value,
|
|
'extra': {
|
|
'professor_in_class': False,
|
|
},
|
|
})
|
|
|
|
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_components(self, request, redirect_path=None):
|
|
base_uri = request.build_absolute_uri('/')
|
|
domain = base_uri.rstrip('/')
|
|
default_path = getattr(settings, 'ONLINE_CLASS_DEFAULT_PATH', 'join-class')
|
|
target_path = redirect_path or default_path
|
|
sanitized = str(target_path).strip('/')
|
|
path_value = f"/{sanitized}" if sanitized else '/'
|
|
base_url = f"{domain}/{sanitized}" if sanitized else domain
|
|
return base_url, path_value
|
|
|
|
@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
|
|
|
|
def get_permissions(self):
|
|
if self.request.method == 'GET':
|
|
return [IsAuthenticated()]
|
|
return [AllowAny()]
|
|
|
|
@swagger_auto_schema(
|
|
operation_description="Get course and user data for authenticated user.",
|
|
manual_parameters=[
|
|
openapi.Parameter(
|
|
'slug', openapi.IN_PATH,
|
|
description="Course Slug",
|
|
type=openapi.TYPE_STRING,
|
|
required=True
|
|
)
|
|
],
|
|
responses={
|
|
status.HTTP_200_OK: openapi.Response(
|
|
description="Course data retrieved.",
|
|
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 get(self, request, slug, *args, **kwargs):
|
|
detail_view = CourseDetailAPIView()
|
|
queryset = detail_view.get_queryset()
|
|
course = get_object_or_404(queryset, slug=slug)
|
|
user = request.user
|
|
|
|
course_data = CourseDetailSerializer(course, context={'request': request}).data
|
|
user_data = UserProfileSerializer(user, context={'request': request}).data
|
|
metadata = self._build_metadata(course, {'user_id': user.id, 'extra': {}, 'generated_at': timezone.now().isoformat()})
|
|
|
|
return Response({
|
|
'course': course_data,
|
|
'user': user_data,
|
|
'metadata': metadata,
|
|
}, status=status.HTTP_200_OK)
|
|
|
|
@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 {}
|
|
user_id = payload.get('user_id')
|
|
can_start_online_class = course.professor_id == user_id
|
|
metadata = {
|
|
'status': status_value,
|
|
'has_started': has_started,
|
|
'has_finished': status_value == Course.StatusChoices.FINISHED,
|
|
'professor_in_class': payload.get('extra', {}).get('professor_in_class', False),
|
|
'can_start_online_class': can_start_online_class,
|
|
'scheduled_times': timing_data,
|
|
'generated_at': payload.get('generated_at'),
|
|
'validated_at': timezone.now().isoformat(),
|
|
'redirect_path': payload.get('redirect_path'),
|
|
}
|
|
metadata.update(self._resolve_live_session_timings(course, payload))
|
|
return metadata
|
|
|
|
def _resolve_live_session_timings(self, course: Course, payload: dict) -> dict:
|
|
latest_session = (
|
|
CourseLiveSession.objects.filter(course=course)
|
|
.order_by('-started_at')
|
|
.first()
|
|
)
|
|
|
|
started_at = None
|
|
if latest_session and latest_session.started_at:
|
|
started_at = latest_session.started_at
|
|
else:
|
|
started_at = payload.get('generated_at')
|
|
|
|
ended_at = None
|
|
if latest_session and latest_session.ended_at:
|
|
ended_at = latest_session.ended_at
|
|
elif started_at:
|
|
ended_at = timezone.now()
|
|
|
|
is_online = False
|
|
if latest_session and latest_session.started_at and not latest_session.ended_at:
|
|
is_online = True
|
|
|
|
return {
|
|
'is_online': is_online,
|
|
'livesession_started_at': self._format_datetime(started_at),
|
|
'livesession_ended_at': self._format_datetime(ended_at),
|
|
}
|
|
|
|
@staticmethod
|
|
def _format_datetime(value):
|
|
if not value:
|
|
return None
|
|
if isinstance(value, str):
|
|
return value
|
|
if timezone.is_naive(value):
|
|
value = timezone.make_aware(value, timezone.get_current_timezone())
|
|
return timezone.localtime(value).isoformat()
|
|
|