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.
 
 

561 lines
21 KiB

from django.conf import settings
import logging
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
logger = logging.getLogger(__name__)
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": "abc123xyz789...",
"url": "https://imamjavad.newhorizonco.uk/join-class?token=abc123xyz789...&slug=python-basics",
"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,
'course_slug': course.slug,
'extra': {
'professor_in_class': False,
},
})
# ساخت URL ثابت با token و course slug
entry_url = f"https://imamjavad.newhorizonco.uk/join-class?token={token}&slug={course.slug}"
return Response({
'token': token,
'url': entry_url,
'expires_in': getattr(settings, 'ONLINE_CLASS_TOKEN_TTL', 300),
}, status=status.HTTP_201_CREATED)
@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()},
user=user,
)
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):
logger.info(f"[Online Validate] Request received")
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
token_value = serializer.validated_data['token']
manager = OnlineClassTokenManager()
try:
payload = manager.get_payload(token_value)
logger.info(f"[Online Validate] Token decoded successfully")
except Exception as e:
logger.error(f"[Online Validate] Token decode failed - error={str(e)}")
raise
course_id = payload.get('course_id')
user_id = payload.get('user_id')
if not course_id or not user_id:
logger.warning(f"[Online Validate] Invalid token payload - course_id={course_id} user_id={user_id}")
raise AppAPIException({'message': 'Token payload is invalid.'}, status_code=status.HTTP_400_BAD_REQUEST)
logger.info(f"[Online Validate] Processing for user_id={user_id} course_id={course_id}")
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)
logger.info(f"[Online Validate] Course found - slug={course.slug} is_online={course.is_online}")
course_data = CourseDetailSerializer(course, context={'request': request}).data
user_data = UserProfileSerializer(user, context={'request': request}).data
metadata = self._build_metadata(course, payload, user=user)
logger.info(f"[Online Validate] Success - user_id={user_id} course={course.slug} can_create={metadata.get('can_create_live_session')} can_join={metadata.get('can_join_live_session')}")
return Response({
'course': course_data,
'user': user_data,
'metadata': metadata,
}, status=status.HTTP_200_OK)
def _build_metadata(self, course: Course, payload: dict, user=None) -> 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 = user or UserModel.objects.filter(pk=payload.get('user_id')).first()
user_id = getattr(user, 'id', None)
can_manage = bool(user and user.can_manage_course(course))
live_context = self._build_live_session_context(course)
can_join_live_session = live_context['is_online'] and self._user_can_join_live_session(user, course)
logger.debug(f"[Online Validate Metadata] user_id={user_id} course={course.slug} can_manage={can_manage} is_online={live_context['is_online']} can_join={can_join_live_session}")
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_create_live_session': can_manage and not live_context['is_online'],
'can_join_live_session': can_join_live_session,
'scheduled_times': timing_data,
'generated_at': payload.get('generated_at'),
'validated_at': timezone.now().isoformat(),
'redirect_path': payload.get('redirect_path'),
}
metadata.update(live_context)
return metadata
def _build_live_session_context(self, course: Course) -> dict:
latest_session = (
CourseLiveSession.objects.filter(course=course)
.order_by('-started_at', '-id')
.first()
)
if not latest_session:
return {
'is_online': False,
'live_session': None,
'active_room_id': None,
'livesession_started_at': None,
'livesession_ended_at': None,
}
started_at = latest_session.started_at
ended_at = latest_session.ended_at
is_online = bool(started_at and not ended_at)
live_session_data = {
'id': latest_session.id,
'room_id': latest_session.room_id,
'subject': latest_session.subject,
'started_at': self._format_datetime(started_at),
'ended_at': self._format_datetime(ended_at),
}
return {
'is_online': is_online,
'live_session': live_session_data,
'active_room_id': live_session_data['room_id'] if is_online and live_session_data['room_id'] else None,
'livesession_started_at': live_session_data['started_at'],
'livesession_ended_at': live_session_data['ended_at'],
}
@staticmethod
def _user_can_join_live_session(user, course: Course) -> bool:
if not user:
return False
if user.can_manage_course(course):
return True
return Participant.objects.filter(course=course, student_id=user.id, is_active=True).exists()
@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()