import logging from django.core.exceptions import ImproperlyConfigured from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import status from rest_framework.generics import GenericAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.authentication import TokenAuthentication from rest_framework.response import Response from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi import time import jwt from apps.course.models import Course, CourseLiveSession, Participant, LiveSessionRecording from apps.course.serializers import LiveSessionRoomCreateSerializer, LiveSessionTokenSerializer, LiveSessionRecordedFileSerializer, LiveSessionRecordingSerializer from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError from utils.exceptions import AppAPIException from django.conf import settings logger = logging.getLogger(__name__) class CourseLiveSessionRoomCreateAPIView(GenericAPIView): permission_classes = [IsAuthenticated] authentication_classes = [TokenAuthentication] serializer_class = LiveSessionRoomCreateSerializer @swagger_auto_schema( operation_description="Create a live session room for a course", tags=["Imam-Javad - Course"], manual_parameters=[ openapi.Parameter( 'slug', openapi.IN_PATH, description="Course slug", type=openapi.TYPE_STRING, required=True ) ], responses={ 201: openapi.Response( description="Live session room created successfully" ) } ) def post(self, request, slug, *args, **kwargs): # 1. Standard Permissions Logic course = get_object_or_404(Course, slug=slug) if not request.user.can_manage_course(course): raise AppAPIException({'message': 'Permission denied'}, status_code=403) # 2. Setup ID and Metadata room_id = f"room-{course.id}-imamjavad" subject = f"{course.title} Live Session" # 3. Database Logic - Check FIRST before calling PlugNMeet # Strategy: # 1. Try to find active session (ended_at is NULL) # 2. If not found, try to find ended session with same room_id and reactivate it # 3. If not found, create new session session = None needs_room_creation = False try: # Try to get active session first session = CourseLiveSession.objects.get( course=course, room_id=room_id, ended_at__isnull=True ) needs_room_creation = False logger.info(f"[LiveSession Create] Found active session - session_id={session.id} room_id={room_id}") except CourseLiveSession.DoesNotExist: # No active session, check if there's an old one with same room_id try: session = CourseLiveSession.objects.get( course=course, room_id=room_id ) # Reactivate the old session and mark for room recreation session.ended_at = None session.started_at = timezone.now() session.subject = subject session.save(update_fields=['ended_at', 'started_at', 'subject', 'updated_at']) needs_room_creation = True logger.info(f"[LiveSession Create] Reactivated ended session - session_id={session.id} room_id={room_id}") except CourseLiveSession.DoesNotExist: # No session exists at all, create new one and mark for room creation session = CourseLiveSession.objects.create( course=course, room_id=room_id, subject=subject, started_at=timezone.now() ) needs_room_creation = True logger.info(f"[LiveSession Create] Created new session - session_id={session.id} room_id={room_id}") # 4. Create room in PlugNMeet ONLY if needed if needs_room_creation: metadata = self._build_metadata(subject) try: client = PlugNMeetClient() plugnmeet_response = client.create_room({ 'room_id': room_id, 'empty_timeout':90, 'metadata': metadata, }) logger.info(f"[LiveSession Create] Room created in PlugNMeet - room_id={room_id}") except Exception as exc: logger.error(f"[LiveSession Create] PlugNMeet Error: {exc}") # If room creation fails, revert the session changes if session.ended_at is None: session.ended_at = timezone.now() session.save(update_fields=['ended_at', 'updated_at']) raise AppAPIException({'message': f'Failed to create room: {str(exc)}'}, status_code=500) else: logger.info(f"[LiveSession Create] Skipping room creation - room already exists - room_id={room_id}") # 5. Generate the JOIN TOKEN (The Entry Ticket) token_payload = { "room_id": room_id, "user_info": { "name": f"{request.user.first_name} {request.user.last_name}", "user_id": str(request.user.id), "is_admin": True, "is_hidden": False } } pnm_token = jwt.encode( { "iss": settings.PLUGNMEET_API_KEY, "exp": int(time.time()) + 3600, "sub": str(request.user.id), **token_payload }, settings.PLUGNMEET_API_SECRET, algorithm="HS256" ) logger.info(f"[LiveSession Create] Success - session_id={session.id} room_id={room_id} user_id={request.user.id}") return Response({ 'success': True, 'session': {'id': session.id, 'room_id': session.room_id}, 'access_token': pnm_token }, status=201) # def post(self, request, slug, *args, **kwargs): # logger.info(f"[LiveSession Create] Request from user_id={request.user.id} for course={slug}") # data = dict(request.data or {}) # if 'metadata' in data: # logger.warning("[LiveSession Create] 'metadata' provided by client will be ignored for security reasons.") # data.pop('metadata', None) # serializer = self.get_serializer(data=data) # serializer.is_valid(raise_exception=True) # course = get_object_or_404(Course, slug=slug) # if not request.user.can_manage_course(course): # logger.warning(f"[LiveSession Create] Permission denied - user_id={request.user.id} course={slug}") # raise AppAPIException({'message': 'You do not have permission to create a live session for this course.'}, status_code=status.HTTP_403_FORBIDDEN) # logger.info(f"[LiveSession Create] Permission granted for user_id={request.user.id} course={slug}") # subject = serializer.validated_data.get('subject') or f"{course.title} Live Session" # room_id = self._build_room_id(course) # metadata = self._build_metadata(subject) # payload = { # 'room_id': room_id, # 'metadata': metadata, # } # logger.info(f"[LiveSession Create] Calling PlugNMeet API - room_id={room_id} course={slug}") # try: # client = PlugNMeetClient() # plugnmeet_response = client.create_room(payload) # logger.info(f"[LiveSession Create] PlugNMeet room created successfully - room_id={room_id}") # except ImproperlyConfigured as exc: # logger.error(f"[LiveSession Create] Configuration error - {str(exc)}") # raise AppAPIException({'message': str(exc)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) # except PlugNMeetError as exc: # logger.error(f"[LiveSession Create] PlugNMeet API error - room_id={room_id} error={str(exc)}") # detail = exc.response_data or {'message': str(exc)} # status_code = exc.status_code or status.HTTP_502_BAD_GATEWAY # raise AppAPIException(detail, status_code=status_code) # session, created = CourseLiveSession.objects.get_or_create( # course=course, # room_id=room_id, # defaults={ # 'subject': subject, # 'started_at': timezone.now(), # }, # ) # if created: # logger.info(f"[LiveSession Create] New session created - session_id={session.id} room_id={room_id} course={slug}") # else: # logger.info(f"[LiveSession Create] Existing session reactivated - session_id={session.id} room_id={room_id} course={slug}") # updates = {} # if session.subject != subject: # session.subject = subject # updates['subject'] = subject # if session.room_id != room_id: # session.room_id = room_id # updates['room_id'] = room_id # if session.started_at is None: # session.started_at = timezone.now() # updates['started_at'] = session.started_at # if updates: # session.save(update_fields=list(updates.keys())) # logger.info(f"[LiveSession Create] Session updated - session_id={session.id} fields={list(updates.keys())}") # logger.info(f"[LiveSession Create] Success - session_id={session.id} room_id={room_id} course={slug} user_id={request.user.id}") # return Response({ # 'session': { # 'id': session.id, # 'room_id': session.room_id, # 'subject': session.subject, # 'started_at': session.started_at, # }, # 'plugnmeet': plugnmeet_response, # }, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK) @staticmethod def _build_room_id(course: Course) -> str: return f"room-{course.id}-imamjavad" def _build_metadata(self, subject: str) -> dict: # Build secured, centralized metadata. Client overrides are NOT allowed. return { 'room_title': subject, 'default_lock_settings': { 'lock_microphone': True, 'lock_webcam': True, 'lock_screen_sharing': True, 'lock_whiteboard': False, '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': True, 'enable_auto_cloud_recording': False, 'only_record_admin_webcams': False, }, }, } def _deep_update(self, base: dict, overrides: dict) -> dict: for key, value in overrides.items(): if isinstance(value, dict) and isinstance(base.get(key), dict): base[key] = self._deep_update(base.get(key, {}), value) else: base[key] = value return base class CourseLiveSessionTokenAPIView(GenericAPIView): permission_classes = [IsAuthenticated] authentication_classes = [TokenAuthentication] serializer_class = LiveSessionTokenSerializer @swagger_auto_schema( operation_description="Generate access token for live session", tags=["Imam-Javad - Course"], responses={ 200: openapi.Response( description="Live session token generated successfully" ) } ) def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) course_slug = serializer.validated_data['course_slug'] user = request.user logger.info(f"[LiveSession Token] Request from user_id={user.id} for course={course_slug}") try: course = Course.objects.get(slug=course_slug) except Course.DoesNotExist: logger.warning(f"[LiveSession Token] Course not found - course={course_slug} user_id={user.id}") raise AppAPIException({'message': 'Course not found.'}, status_code=status.HTTP_404_NOT_FOUND) if not course.is_online: logger.warning(f"[LiveSession Token] Course not configured for online - course={course_slug} user_id={user.id}") raise AppAPIException({'message': 'Course is not configured for online sessions.'}, status_code=status.HTTP_400_BAD_REQUEST) try: session = CourseLiveSession.objects.select_related('course').get( course=course, ended_at__isnull=True ) logger.info(f"[LiveSession Token] Active session found in DB - session_id={session.id} room_id={session.room_id} course={course_slug}") except CourseLiveSession.DoesNotExist: logger.warning(f"[LiveSession Token] No active session found - course={course_slug} user_id={user.id}") raise AppAPIException({'message': 'No active live session found for this course.'}, status_code=status.HTTP_404_NOT_FOUND) # Check user role first to determine permissions is_admin = user.can_manage_course(course) user_role = "professor" if is_admin else "student" logger.info(f"[LiveSession Token] User role determined - user_id={user.id} role={user_role} course={course_slug}") # CRITICAL: Verify the room is actually active in PlugNMeet before issuing token # This prevents issuing tokens for rooms that have crashed or ended without webhook notification room_id = session.room_id room_is_active = self._verify_room_is_active(session) if not room_is_active: # Room is not active in PlugNMeet but we have a session record if is_admin: # For professors: Auto-recreate the room in PlugNMeet logger.info(f"[LiveSession Token] Room inactive but professor requesting - recreating room - room_id={room_id} session_id={session.id}") try: self._recreate_room_in_plugnmeet(course, session) logger.info(f"[LiveSession Token] Room recreated successfully - room_id={room_id}") except Exception as e: logger.error(f"[LiveSession Token] Failed to recreate room - room_id={room_id} error={str(e)}") raise AppAPIException({ 'status': 'False', 'message': f'Failed to recreate room: {str(e)}', 'msg': f'Failed to recreate room: {str(e)}' }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) else: # For students: Refuse token - they cannot create rooms logger.error(f"[LiveSession Token] Room not active and user is student - refusing token - room_id={room_id} user_id={user.id}") raise AppAPIException({ 'status': 'False', 'message': 'room is not active. Please wait for the professor to start the class.', 'msg': 'room is not active. Please wait for the professor to start the class.' }, status_code=status.HTTP_400_BAD_REQUEST) if not is_admin and not Participant.objects.filter(course=course, student_id=user.id, is_active=True).exists(): logger.warning(f"[LiveSession Token] Access denied - user_id={user.id} not enrolled in course={course_slug}") raise AppAPIException({'message': 'You do not have access to this live session.'}, status_code=status.HTTP_403_FORBIDDEN) 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) logger.info(f"[LiveSession Token] Profile pic URL - user_id={user.id} url={profile_pic}") 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': False, '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 payload = { 'room_id': room_id, 'user_info': user_info, } logger.info(f"[LiveSession Token] Requesting token from PlugNMeet - room_id={room_id} user_id={user.id} role={user_role}") try: client = PlugNMeetClient() plugnmeet_response = client.get_join_token(payload) logger.info(f"[LiveSession Token] Token generated successfully - room_id={room_id} user_id={user.id}") except ImproperlyConfigured as exc: logger.error(f"[LiveSession Token] Configuration error - {str(exc)}") raise AppAPIException({'message': str(exc)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) except PlugNMeetError as exc: logger.error(f"[LiveSession Token] PlugNMeet API error - room_id={room_id} user_id={user.id} error={str(exc)}") detail = exc.response_data or {'message': str(exc)} status_code = exc.status_code or status.HTTP_502_BAD_GATEWAY raise AppAPIException(detail, status_code=status_code) logger.info(f"[LiveSession Token] Success - room_id={room_id} user_id={user.id} role={user_role} course={course_slug}") return Response({ 'room_id': room_id, 'token': plugnmeet_response.get('token'), 'plugnmeet': plugnmeet_response, }) def _build_metadata(self, subject: str) -> dict: # Build secured, centralized metadata. Client overrides are NOT allowed. return { 'room_title': subject, 'default_lock_settings': { 'lock_microphone': True, 'lock_webcam': True, 'lock_screen_sharing': True, 'lock_whiteboard': False, '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': True, 'enable_auto_cloud_recording': False, 'only_record_admin_webcams': False, }, }, } @staticmethod def _verify_room_is_active(session: CourseLiveSession) -> bool: """ Verify that the room is actually active in PlugNMeet. Args: session: The CourseLiveSession to verify Returns: bool: True if room is active in PlugNMeet, False otherwise Side effects: - Closes session in database if PlugNMeet reports room is inactive """ if not session.room_id: logger.warning(f"[Room Verify] 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 logger.debug(f"[Room Verify] PlugNMeet response - room_id={session.room_id} response={response}") # Handle isActive as boolean or string 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) # Trust status and msg if they indicate active room 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 Verify] ✓ Room is active - room_id={session.room_id} session_id={session.id}") return True else: logger.warning(f"[Room Verify] ✗ Room is NOT active - room_id={session.room_id} session_id={session.id} msg={response_msg}") # Auto-close the session since room is not active now = timezone.now() session.ended_at = now session.save(update_fields=['ended_at', 'updated_at']) logger.info(f"[Room Verify] Session auto-closed - session_id={session.id} room_id={session.room_id}") return False except PlugNMeetError as e: error_msg = str(e) logger.error(f"[Room Verify] PlugNMeet API error - room_id={session.room_id} error={error_msg}") # If room not found, close the session if 'not found' in error_msg.lower() or 'does not exist' in error_msg.lower(): now = timezone.now() session.ended_at = now session.save(update_fields=['ended_at', 'updated_at']) logger.warning(f"[Room Verify] Room not found - session closed - session_id={session.id}") return False except Exception as e: logger.error(f"[Room Verify] Unexpected error - room_id={session.room_id} error={type(e).__name__}: {str(e)}") return False def _recreate_room_in_plugnmeet(self, course: Course, session: CourseLiveSession) -> None: """ Recreate a room in PlugNMeet when session exists but room is inactive. This happens when: - Webhook failed to notify us of room closure - PlugNMeet server restarted - Room was manually ended Args: course: The course for which to recreate the room session: The existing session to reactivate Raises: PlugNMeetError: If room creation fails """ subject = session.subject or f"{course.title} Live Session" room_id = session.room_id metadata = self._build_metadata(subject) payload = { 'room_id': room_id, 'metadata': metadata, } logger.info(f"[Room Recreate] Recreating room in PlugNMeet - room_id={room_id} session_id={session.id}") try: client = PlugNMeetClient() plugnmeet_response = client.create_room(payload) logger.info(f"[Room Recreate] Room recreated successfully - room_id={room_id} response={plugnmeet_response}") # Reset session ended_at to mark it as active again session.ended_at = None session.save(update_fields=['ended_at', 'updated_at']) logger.info(f"[Room Recreate] Session reactivated - session_id={session.id}") except PlugNMeetError as exc: logger.error(f"[Room Recreate] Failed to recreate room - room_id={room_id} error={str(exc)}") raise @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 class CourseLiveSessionRecordedFileAPIView(GenericAPIView): permission_classes = [IsAuthenticated] authentication_classes = [TokenAuthentication] serializer_class = LiveSessionRecordingSerializer @swagger_auto_schema( operation_description="Update recorded file for live session", tags=["Imam-Javad - Course"], manual_parameters=[ openapi.Parameter( 'course_id', openapi.IN_PATH, description="Course ID", type=openapi.TYPE_INTEGER, required=True ) ], responses={ 200: openapi.Response( description="Recorded file updated successfully" ) } ) def patch(self, request, course_id, *args, **kwargs): logger.info(f"[LiveSession Recorded File] Request from user_id={request.user.id} for course_id={course_id}") course = get_object_or_404(Course, id=course_id) if not request.user.can_manage_course(course): logger.warning(f"[LiveSession Recorded File] Permission denied - user_id={request.user.id} course_id={course_id}") raise AppAPIException({'message': 'You do not have permission to update this course.'}, status_code=status.HTTP_403_FORBIDDEN) try: session = course.live_sessions.latest('-started_at') except CourseLiveSession.DoesNotExist: logger.warning(f"[LiveSession Recorded File] No active session found - course_id={course_id} user_id={request.user.id}") raise AppAPIException({'message': 'No live session found for this course.'}, status_code=status.HTTP_404_NOT_FOUND) logger.info(f"[LiveSession Recorded File] Latest session found - session_id={session.id} course_id={course_id}") serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) recording = LiveSessionRecording.objects.create( session=session, file=serializer.validated_data['file'], title=serializer.validated_data.get('title') or f"{session.subject} Recording", recording_type=serializer.validated_data.get('recording_type', 'video'), file_time=serializer.validated_data.get('file_time'), ) logger.info(f"[LiveSession Recorded File] Recording created successfully - recording_id={recording.id} session_id={session.id} user_id={request.user.id}") return Response({ 'id': recording.id, 'session_id': session.id, 'title': recording.title, 'file': request.build_absolute_uri(recording.file.url), 'recording_type': recording.recording_type, 'file_time': recording.file_time, 'is_active': recording.is_active, }, status=status.HTTP_201_CREATED)