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, 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 @swagger_auto_schema( operation_description=doc_course_category(), tags=["Imam-Javad - Course"] ) 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( 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 ", type=openapi.TYPE_BOOLEAN, ), openapi.Parameter( 'is_online', openapi.IN_QUERY, description="Статус участия is_online ", 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) class MyCourseListAPIView(ListAPIView): serializer_class = MyCourseListSerializer permission_classes = [IsAuthenticated] @swagger_auto_schema(manual_parameters=[ openapi.Parameter( 'completed', openapi.IN_QUERY, description="мои курсы completed 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 @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'] @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] 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 def get_permissions(self): if self.request.method == 'GET': return [IsAuthenticated()] return [AllowAny()] @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): detail_view = CourseDetailAPIView() queryset = detail_view.get_queryset() course = get_object_or_404(queryset, slug=slug) user = request.user # 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, '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( 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): 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() # 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}")