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.
 
 

724 lines
28 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
from utils.pagination import StandardResultsSetPagination
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,
LiveSessionUser,
Participant,
)
from apps.course.doc import *
from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError
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
pagination_class = StandardResultsSetPagination
@swagger_auto_schema(
operation_description=doc_course_category(),
tags=["Imam-Javad - Course"]
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
from utils.pagination import StandardResultsSetPagination
class CourseListAPIView(ListAPIView):
serializer_class = CourseListSerializer
filter_backends = [SearchFilter]
search_fields = ['title', 'category__name', 'professor__fullname']
pagination_class = StandardResultsSetPagination
@swagger_auto_schema(
tags=['Imam-Javad - Course'],
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 self.list(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"
@swagger_auto_schema(
tags=["Imam-Javad - Course"],
operation_description="Get detailed information about a specific course",
responses={
200: openapi.Response(
description="Course details",
schema=CourseDetailSerializer()
)
}
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
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(),
tags=['Imam-Javad - Course'],
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
from rest_framework.authentication import TokenAuthentication
class MyCourseListAPIView(ListAPIView):
serializer_class = MyCourseListSerializer
permission_classes = [IsAuthenticated]
pagination_class = StandardResultsSetPagination
authentication_classes = [TokenAuthentication]
@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",
tags=['Imam-Javad - Course']
)
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
pagination_class = StandardResultsSetPagination
@swagger_auto_schema(
tags=['Imam-Javad - Course'],
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']
pagination_class = StandardResultsSetPagination
@swagger_auto_schema(
operation_description="Get glossary terms for a specific course",
tags=["Imam-Javad - Course"],
manual_parameters=[
openapi.Parameter(
'slug', openapi.IN_PATH,
description="Course slug",
type=openapi.TYPE_STRING,
required=True
),
openapi.Parameter(
'search', openapi.IN_QUERY,
description="Search in glossary title or description",
type=openapi.TYPE_STRING,
required=False
)
],
responses={
200: openapi.Response(
description="List of glossary terms",
schema=CourseGlossarySerializer(many=True)
)
}
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
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]
authentication_classes = [TokenAuthentication]
serializer_class = OnlineClassTokenCreateSerializer
@swagger_auto_schema(
tags=['Imam-Javad - Course'],
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
@swagger_auto_schema(
tags=['Imam-Javad - Course'],
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):
print("=" * 80)
print(f"[Online Validate GET] REQUEST RECEIVED {request.data}")
print(f"[Online Validate GET] slug={slug}")
print(f"[Online Validate GET] user={request.user}")
print(f"[Online Validate GET] user.is_authenticated={request.user.is_authenticated}")
print(f"[Online Validate GET] user.id={request.user.id if request.user.is_authenticated else 'N/A'}")
print(f"[Online Validate GET] Authorization Header={request.META.get('HTTP_AUTHORIZATION', 'NOT FOUND')}")
print(f"[Online Validate GET] All Headers={dict((k, v) for k, v in request.META.items() if k.startswith('HTTP_'))}")
# Debug: Check if token exists in database
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
if auth_header.startswith('Token '):
token_key = auth_header.split(' ')[1]
try:
from rest_framework.authtoken.models import Token
token_obj = Token.objects.get(key=token_key)
print(f"[Online Validate GET] Token found in DB - user={token_obj.user} user_id={token_obj.user.id}")
except Token.DoesNotExist:
print(f"[Online Validate GET] Token NOT found in DB - token={token_key[:10]}...")
except Exception as e:
print(f"[Online Validate GET] Token check error - {str(e)}")
print("=" * 80)
logger.info(f"[Online Validate GET] Request received - slug={slug} user_id={request.user.id if request.user.is_authenticated else 'anonymous'}")
detail_view = CourseDetailAPIView()
queryset = detail_view.get_queryset()
course = get_object_or_404(queryset, slug=slug)
user = request.user
print(f"[Online Validate GET] Course found - course_id={course.id} slug={slug} is_online={course.is_online}")
logger.info(f"[Online Validate GET] Course found - course_id={course.id} slug={slug} is_online={course.is_online}")
# DEPRECATED: Polling approach replaced by webhook integration
# Room status is now updated automatically via PlugNMeet webhooks
# self._sync_room_status_with_plugnmeet(course)
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 if user.is_authenticated else None, 'extra': {}, 'generated_at': timezone.now().isoformat()},
user=user,
)
print(f"[Online Validate GET] Success - metadata={metadata}")
logger.info(f"[Online Validate GET] Success - user_id={user.id if user.is_authenticated else 'anonymous'} 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)
@swagger_auto_schema(
tags=['Imam-Javad - Course'],
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):
print("=" * 80)
print(f"[Online Validate POST] REQUEST RECEIVED")
print(f"[Online Validate POST] request.data={request.data}")
print(f"[Online Validate POST] has_token={'token' in request.data}")
print("=" * 80)
logger.info(f"[Online Validate POST] Request received - has_token={'token' in request.data}")
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
token_value = serializer.validated_data['token']
print(f"[Online Validate POST] Token extracted - token={token_value[:16]}...")
logger.info(f"[Online Validate POST] Token extracted - token={token_value[:16]}...")
manager = OnlineClassTokenManager()
try:
payload = manager.get_payload(token_value)
print(f"[Online Validate POST] Token decoded successfully - payload={payload}")
logger.info(f"[Online Validate POST] Token decoded successfully - payload={payload}")
except Exception as e:
print(f"[Online Validate POST] Token decode FAILED - error={str(e)} type={type(e).__name__}")
logger.error(f"[Online Validate POST] Token decode failed - error={str(e)} type={type(e).__name__}")
raise
course_id = payload.get('course_id')
user_id = payload.get('user_id')
if not course_id or not user_id:
print(f"[Online Validate POST] Invalid token payload - course_id={course_id} user_id={user_id}")
logger.warning(f"[Online Validate POST] 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)
print(f"[Online Validate POST] Processing for user_id={user_id} course_id={course_id}")
logger.info(f"[Online Validate POST] 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)
print(f"[Online Validate POST] Course found - slug={course.slug} is_online={course.is_online}")
logger.info(f"[Online Validate POST] 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)
print(f"[Online Validate POST] Success - metadata={metadata}")
logger.info(f"[Online Validate POST] 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()
# DEPRECATED: This polling approach is inefficient and has been replaced by webhook integration
# def _sync_room_status_with_plugnmeet(self, course: Course):
# """
# Check if active live session's room is still active in PlugNMeet.
# If room is inactive, close the session and all related user entries.
#
# DEPRECATED: This should be replaced by webhook integration.
# PlugNMeet now sends webhooks when rooms end, eliminating the need for polling.
# """
# active_session = CourseLiveSession.objects.filter(
# course=course,
# ended_at__isnull=True
# ).first()
#
# if not active_session or not active_session.room_id:
# return
#
# try:
# client = PlugNMeetClient()
# response = client.is_room_active(active_session.room_id)
# is_active = response.get('isActive', False)
#
# if not is_active:
# logger.info(f"[Room Sync] Room inactive in PlugNMeet - room_id={active_session.room_id} session_id={active_session.id}")
# self._close_live_session(active_session)
# else:
# logger.debug(f"[Room Sync] Room still active - room_id={active_session.room_id} session_id={active_session.id}")
#
# except (PlugNMeetError, Exception) as e:
# logger.warning(f"[Room Sync] Failed to check room status - room_id={active_session.room_id} error={str(e)}")
@staticmethod
def _close_live_session(session: CourseLiveSession):
"""
Close a live session and all related user entries.
Sets ended_at for session and exited_at/is_online for users.
"""
now = timezone.now()
session.ended_at = now
session.save(update_fields=['ended_at', 'updated_at'])
logger.info(f"[Room Sync] Session closed - session_id={session.id} room_id={session.room_id} ended_at={now}")
updated_count = LiveSessionUser.objects.filter(
session=session,
is_online=True,
exited_at__isnull=True
).update(
is_online=False,
exited_at=now,
updated_at=now
)
if updated_count > 0:
logger.info(f"[Room Sync] User sessions closed - session_id={session.id} count={updated_count}")