import logging from typing import Optional from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured 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.authentication import TokenAuthentication 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.models.course import extract_text_from_json from apps.course.doc import * from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError from apps.course.services.web_egress import stop_session_web_egress_if_active from apps.account.serializers import UserProfileSerializer from utils.exceptions import AppAPIException from utils.redis import OnlineClassTokenManager import time UserModel = get_user_model() def get_course_slug_value(course: Course) -> str: slug_data = course.slug if isinstance(slug_data, list) and slug_data: first_item = slug_data[0] if isinstance(first_item, dict): first_title = first_item.get('title') if first_title: return str(first_title) if isinstance(slug_data, str): return slug_data return str(slug_data or '') def get_online_class_frontend_base() -> str: base = getattr( settings, "ONLINE_CLASS_FRONTEND_DOMAIN", getattr(settings, "SITE_DOMAIN", ""), ).rstrip("/") if base and not base.startswith(('http://', 'https://')): base = f"https://{base}" return base class CourseCategoryAPIView(ListAPIView): queryset = CourseCategory.objects.all().order_by('-id') serializer_class = CourseCategorySerializer pagination_class = StandardResultsSetPagination permission_classes = [AllowAny] authentication_classes = [TokenAuthentication] @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', 'professors__fullname'] pagination_class = StandardResultsSetPagination permission_classes = [AllowAny] authentication_classes = [TokenAuthentication] @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', ).prefetch_related( 'professors', ).exclude( status__contains=[{"title": Course.StatusChoices.INACTIVE}] ).order_by('-created_at', '-id') 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(',') category_q = Q() for slug_item in category_slugs_list: category_q |= Q(category__slug__contains=[{"title": slug_item}]) queryset = queryset.filter(category_q) # Handle status with multiple values separated by commas if statuses := filters.get('status'): statuses_list = statuses.split(',') status_q = Q() for status_item in statuses_list: status_q |= Q(status__contains=[{"title": status_item}]) queryset = queryset.filter(status_q) if is_free := filters.get('is_free'): is_free = is_free.lower() == 'true' queryset = queryset.filter( Q(is_free=is_free) | Q(price_rub=0) if is_free else Q(is_free=False, price_rub__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" permission_classes = [AllowAny] authentication_classes = [TokenAuthentication] @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', ).prefetch_related( 'professors', 'chapters__lessons__lesson', 'chapters__lessons__completions', 'attachments__attachment', 'glossaries__glossary', 'participants__student', 'room_messages' ) def get_object(self): queryset = self.filter_queryset(self.get_queryset()) lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field slug_value = self.kwargs[lookup_url_kwarg] obj = queryset.filter(slug__contains=[{"title": slug_value}]).first() if not obj: raise NotFound("Course not found") self.check_object_permissions(self.request, obj) return obj @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 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 (as student and professor) with select_related and prefetch_related """ queryset = Course.objects.select_related( 'category', ).prefetch_related( 'professors', 'chapters__lessons__lesson', 'chapters__lessons__completions', 'participants__student' ).exclude(status__contains=[{"title": Course.StatusChoices.INACTIVE}]) request = self.request filters = request.query_params user = self.request.user # Include courses where user is a student OR one of the professors qs = queryset.filter(Q(participants__student=user) | Q(professors=user)).distinct() completed_param = filters.get('completed') if completed_param is not None: completed_only = completed_param.lower() == 'true' # Only active chapters/lessons should participate in completion checks. total_lessons_filter = Q( chapters__is_active=True, chapters__lessons__is_active=True, ) completed_lessons_filter = Q( chapters__is_active=True, chapters__lessons__is_active=True, chapters__lessons__completions__student=user, ) qs = qs.annotate( total_lessons=Count( 'chapters__lessons', filter=total_lessons_filter, distinct=True, ), completed_lessons=Count( 'chapters__lessons__completions', filter=completed_lessons_filter, distinct=True, ), ) if completed_only: # A course is "completed" only when every active lesson is completed. # Courses with no active lessons should not be marked as completed. qs = qs.filter( total_lessons__gt=0, total_lessons=F('completed_lessons'), ) else: qs = qs.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=user, course_certificates__status__in=['pending', 'approved'] ) return qs class AttachmentListAPIView(ListAPIView): serializer_class = CourseAttachmentSerializer pagination_class = StandardResultsSetPagination permission_classes = [AllowAny] authentication_classes = [TokenAuthentication] @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') course = Course.objects.filter(slug__contains=[{"title": course_slug}]).first() if not course: 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 permission_classes = [AllowAny] authentication_classes = [TokenAuthentication] @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') course = Course.objects.filter(slug__contains=[{"title": course_slug}]).first() if not course: 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.online/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': get_course_slug_value(course), 'extra': { 'professor_in_class': False, }, }) # ساخت URL ثابت با token و course slug frontend_base = get_online_class_frontend_base() course_slug = get_course_slug_value(course) entry_url = f"{frontend_base}/?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.professors.filter(id=user.id).exists(): return True return Participant.objects.filter(course=course, student=user).exists() class CourseOnlineClassTokenValidateAPIView(GenericAPIView): # Changed from AllowAny to enable DRF authentication # Users can still access without auth, but if token is provided, it will be authenticated authentication_classes = [TokenAuthentication] 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("=" * 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 = queryset.filter(slug__contains=[{"title": slug}]).first() if not course: raise NotFound("Course not found") 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, ) redirect_path = self._resolve_redirect_path(request, course, user, metadata) metadata['redirect_path'] = redirect_path 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, 'redirect_path': redirect_path, }, 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) redirect_path = self._resolve_redirect_path(request, course, user, metadata, token_value=token_value) metadata['redirect_path'] = redirect_path 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, 'redirect_path': redirect_path, }, status=status.HTTP_200_OK) def _build_metadata(self, course: Course, payload: dict, user=None) -> dict: status_value = extract_text_from_json(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': None, } metadata.update(live_context) return metadata def _resolve_redirect_path(self, request, course: Course, user, metadata: dict, token_value: str = None) -> Optional[str]: if not user or not user.is_authenticated: return None if metadata.get('is_online') and metadata.get('can_join_live_session'): redirect_path = self._build_live_redirect_path(request, course, user) if redirect_path: return redirect_path if metadata.get('can_create_live_session'): redirect_path = self._build_professor_direct_redirect_path(course, user) if redirect_path: return redirect_path if self._user_has_course_access(user, course): return self._build_waiting_redirect_path(course, user, token_value=token_value) return None def _build_waiting_redirect_path(self, course: Course, user, token_value: str = None) -> str: course_slug = get_course_slug_value(course) token = token_value or self._generate_entry_token(course, user) frontend_base = get_online_class_frontend_base() return f"{frontend_base}/?token={token}&slug={course_slug}" def _build_professor_direct_redirect_path(self, course: Course, user) -> Optional[str]: try: session = self._ensure_professor_live_session(course) redirect_path = self._build_live_redirect_path_for_room( course=course, user=user, room_id=session.room_id, ) if redirect_path: return redirect_path except (ImproperlyConfigured, PlugNMeetError, Exception) as exc: logger.warning( "[Online Validate] Failed to create professor live session - course=%s user_id=%s error=%s", course.slug, user.id, str(exc), ) return None return None def _ensure_professor_live_session(self, course: Course) -> CourseLiveSession: session = ( CourseLiveSession.objects.filter(course=course, ended_at__isnull=True) .order_by('-started_at', '-id') .first() ) if session and session.room_id: return session subject = self._get_live_session_subject(course) room_id = f"room-{course.id}-{int(time.time())}" session = CourseLiveSession.objects.create( course=course, room_id=room_id, subject=subject, started_at=timezone.now(), ) client = PlugNMeetClient() client.create_room({ 'room_id': room_id, 'empty_timeout': 90, 'metadata': self._build_live_room_metadata(subject), }) return session @staticmethod def _get_live_session_subject(course: Course) -> str: next_session_number = course.live_sessions.count() + 1 return f"Live Session {next_session_number}" def _build_live_room_metadata(self, subject: str) -> dict: return { 'room_title': subject, 'webhook_url': self._get_plugnmeet_webhook_url(), 'default_lock_settings': { 'lock_microphone': True, 'lock_webcam': True, 'lock_screen_sharing': True, 'lock_whiteboard': True, 'lock_shared_notepad': False, 'lock_chat': False, 'lock_chat_send_message': False, 'lock_chat_file_share': False, 'lock_private_chat': False, }, 'room_features': { 'allow_webcams': True, 'mute_on_start': True, 'allow_screen_sharing': True, 'allow_recording': True, 'allow_rtmp': False, 'allow_view_other_webcams': True, 'allow_view_other_participants_list': True, 'admin_only_webcams': False, 'allow_polls': True, 'room_duration': 0, 'chat_features': { 'allow_chat': True, 'allow_file_upload': True, }, 'shared_note_pad_features': { 'allowed_shared_note_pad': True, }, 'whiteboard_features': { 'allowed_whiteboard': True, }, 'breakout_room_features': { 'is_allow': True, 'allowed_number_rooms': 6, }, 'waiting_room_features': { 'is_active': False, }, 'recording_features': { 'is_allow': True, 'is_allow_cloud': True, 'is_allow_local': False, 'enable_auto_cloud_recording': False, 'only_record_admin_webcams': False, }, }, } @staticmethod def _get_plugnmeet_webhook_url() -> str: base = getattr(settings, 'SITE_DOMAIN', '').rstrip('/') if not base: raise ImproperlyConfigured('SITE_DOMAIN must be configured for PlugNMeet webhook delivery.') if not base.startswith(('http://', 'https://')): base = f"https://{base}" return f"{base}/api/courses/plugnmeet/webhook/" def _build_live_redirect_path(self, request, course: Course, user) -> Optional[str]: session = ( CourseLiveSession.objects.filter(course=course, ended_at__isnull=True) .order_by('-started_at', '-id') .first() ) if not session or not session.room_id: return None return self._build_live_redirect_path_for_room( course=course, user=user, room_id=session.room_id, request=request, ) def _build_live_redirect_path_for_room(self, course: Course, user, room_id: str, request=None) -> Optional[str]: if not room_id: return None is_admin = bool(user.can_manage_course(course)) user_info = { 'user_id': str(user.id), 'name': user.get_full_name() or user.email or user.username or f"user-{user.id}", 'is_admin': is_admin, } user_metadata = {} profile_pic = self._build_profile_url(request, user) if request is not None else None if profile_pic: user_metadata['profilePic'] = profile_pic if not is_admin: user_metadata['lock_settings'] = { 'lock_microphone': True, 'lock_screen_sharing': True, 'lock_webcam': True, 'lock_whiteboard': True, 'lock_shared_notepad': False, 'lock_chat': False, 'lock_chat_send_message': False, 'lock_chat_file_share': False, 'lock_private_chat': False, } else: user_metadata['is_hidden'] = False if user_metadata: user_info['user_metadata'] = user_metadata try: client = PlugNMeetClient() response = client.get_join_token({ 'room_id': room_id, 'user_info': user_info, }) except (PlugNMeetError, Exception) as exc: logger.warning( "[Online Validate] Failed to generate direct access token - course=%s user_id=%s error=%s", course.slug, user.id, str(exc), ) return None access_token = response.get('token') if not access_token: return None self._register_fail_safe_live_session_user( course=course, room_id=room_id, user=user, is_admin=is_admin, ) frontend_base = get_online_class_frontend_base() return f"{frontend_base}/?access_token={access_token}" @staticmethod def _register_fail_safe_live_session_user( course: Course, room_id: str, user, is_admin: bool, ) -> None: try: session = ( CourseLiveSession.objects.filter( course=course, room_id=room_id, ended_at__isnull=True, ) .order_by('-started_at', '-id') .first() ) if not session: logger.warning( "[Online Validate] Fail-safe registration skipped - no active session found for course=%s room_id=%s user_id=%s", course.slug, room_id, user.id, ) return role = 'moderator' if is_admin else 'participant' LiveSessionUser.objects.update_or_create( session=session, user=user, defaults={ 'role': role, 'is_online': True, 'exited_at': None, 'entered_at': timezone.now(), }, ) if is_admin: session.last_moderator_left_at = None session.auto_close_after_moderator_exit_at = None session.save( update_fields=[ 'last_moderator_left_at', 'auto_close_after_moderator_exit_at', 'updated_at', ] ) logger.info( "[Online Validate] Fail-safe registered user entry - session_id=%s room_id=%s user_id=%s role=%s", session.id, room_id, user.id, role, ) except Exception as exc: logger.error( "[Online Validate] Failed to register fail-safe user entry - course=%s room_id=%s user_id=%s error=%s", course.slug, room_id, user.id, str(exc), ) def _generate_entry_token(self, course: Course, user) -> str: manager = OnlineClassTokenManager() user_token, _ = Token.objects.get_or_create(user=user) identifier = f"{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': user.id, 'user_token': user_token.key, 'course_slug': get_course_slug_value(course), 'extra': { 'professor_in_class': False, }, }) return token @staticmethod def _user_has_course_access(user, course: Course) -> bool: if not user or not user.is_authenticated: return False if user.is_staff or user.can_manage_course(course): return True return Participant.objects.filter(course=course, student=user).exists() @staticmethod def _build_profile_url(request, user): avatar = getattr(user, 'avatar', None) if avatar and getattr(avatar, 'url', None): return request.build_absolute_uri(avatar.url) return None def _build_live_session_context(self, course: Course) -> dict: """ Build live session context with real-time PlugNMeet verification. This method: 1. Finds the latest session for the course 2. Verifies with PlugNMeet if the room is actually active 3. Auto-closes sessions if PlugNMeet reports room is inactive 4. Returns accurate session state independent of webhook delays """ latest_session = ( CourseLiveSession.objects.filter(course=course) .order_by('-started_at', '-id') .first() ) if not latest_session: logger.debug(f"[Live Session Context] No session found for course={course.slug}") 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) # CRITICAL: Verify room status with PlugNMeet if session appears online # This ensures we don't rely solely on webhooks which may fail or be delayed if is_online and latest_session.room_id: is_online = self._verify_and_sync_room_status(latest_session) # Refresh ended_at in case session was closed ended_at = latest_session.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), } context = { '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'], } logger.debug(f"[Live Session Context] course={course.slug} is_online={is_online} room_id={live_session_data['room_id']}") return context def _verify_and_sync_room_status(self, session: CourseLiveSession) -> bool: """ Verify room status with PlugNMeet and sync local database. Args: session: The CourseLiveSession to verify Returns: bool: True if room is active, False if inactive or verification failed Side effects: - Closes session in database if PlugNMeet reports room is inactive - Updates LiveSessionUser records accordingly """ if not session.room_id: logger.warning(f"[Room Sync] Session has no room_id - session_id={session.id}") return False try: client = PlugNMeetClient() response = client.is_room_active(session.room_id) # Debug: Log full response to understand structure logger.debug(f"[Room Sync] PlugNMeet response - room_id={session.room_id} response={response}") # PlugNMeet returns: {"status": true, "msg": "...", "isActive": true/false} # Note: isActive might be boolean or string, handle both is_active_raw = response.get('isActive', False) is_active = is_active_raw if isinstance(is_active_raw, bool) else str(is_active_raw).lower() == 'true' response_msg = response.get('msg', 'unknown') response_status = response.get('status', False) # Additional check: if status is true and msg says "active", trust that if response_status and 'active' in response_msg.lower() and 'not' not in response_msg.lower(): is_active = True if is_active: logger.debug(f"[Room Sync] ✓ Room verified active - room_id={session.room_id} session_id={session.id} msg={response_msg}") return True else: # Room is not active in PlugNMeet but active in our database # This happens when: # 1. Webhook failed to fire # 2. Room was ended externally # 3. Room crashed or timed out logger.warning(f"[Room Sync] ✗ Room inactive in PlugNMeet - auto-closing session_id={session.id} room_id={session.room_id} msg={response_msg}") self._close_live_session(session) return False except PlugNMeetError as e: # PlugNMeet API returned an error error_msg = str(e) logger.error(f"[Room Sync] PlugNMeet API error - room_id={session.room_id} session_id={session.id} error={error_msg}") # Check if error message indicates room doesn't exist if 'not found' in error_msg.lower() or 'does not exist' in error_msg.lower(): logger.warning(f"[Room Sync] Room not found in PlugNMeet - closing session_id={session.id}") self._close_live_session(session) return False # For other API errors, assume room might still be active (fail-safe) logger.warning(f"[Room Sync] Cannot verify room status, assuming inactive for safety - room_id={session.room_id}") return False except Exception as e: # Network error or unexpected exception logger.error(f"[Room Sync] Unexpected error verifying room - room_id={session.room_id} session_id={session.id} error={type(e).__name__}: {str(e)}") # For network errors, fail-safe: assume room might still be active # but log a warning for monitoring logger.warning(f"[Room Sync] Network/system error, assuming room inactive for safety") return False @staticmethod def _user_can_join_live_session(user, course: Course) -> bool: if not user or not user.is_authenticated: return False if user.is_staff or user.is_superuser or 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)}") def _close_live_session(self, session: CourseLiveSession): """ Close a live session and all related user entries. Sets ended_at for session and exited_at/is_online for users. """ stop_session_web_egress_if_active(session) 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}")