diff --git a/apps/course/management/commands/auto_close_professorless_live_sessions.py b/apps/course/management/commands/auto_close_professorless_live_sessions.py new file mode 100644 index 0000000..4e054d8 --- /dev/null +++ b/apps/course/management/commands/auto_close_professorless_live_sessions.py @@ -0,0 +1,127 @@ +import logging + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + +from apps.course.models import CourseLiveSession, LiveSessionUser +from apps.course.services import PlugNMeetClient, PlugNMeetError + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = ( + "Auto-close live sessions when no moderator has been present for the configured timeout " + "while other participants are still online." + ) + + def handle(self, *args, **options): + now = timezone.now() + sessions = CourseLiveSession.objects.filter( + ended_at__isnull=True, + auto_close_after_moderator_exit_at__isnull=False, + auto_close_after_moderator_exit_at__lte=now, + ).select_related('course') + + processed = 0 + closed = 0 + skipped = 0 + + for session in sessions: + processed += 1 + outcome = self._process_session(session, now) + if outcome == 'closed': + closed += 1 + else: + skipped += 1 + + self.stdout.write( + self.style.SUCCESS( + f"Processed {processed} session(s); closed {closed}; skipped {skipped}." + ) + ) + + def _process_session(self, session: CourseLiveSession, now): + has_moderator_online = LiveSessionUser.objects.filter( + session=session, + role='moderator', + is_online=True, + ).exists() + if has_moderator_online: + self._clear_pending_auto_close(session) + logger.info( + "[Auto Close Professorless] Moderator returned - clearing pending close for session_id=%s", + session.id, + ) + return 'skipped' + + has_any_online_users = LiveSessionUser.objects.filter( + session=session, + is_online=True, + ).exists() + if not has_any_online_users: + self._clear_pending_auto_close(session) + logger.info( + "[Auto Close Professorless] No online users remain - deferring to empty timeout for session_id=%s", + session.id, + ) + return 'skipped' + + try: + client = PlugNMeetClient() + client.end_room(session.room_id) + logger.info( + "[Auto Close Professorless] Ended room via PlugNMeet - session_id=%s room_id=%s", + session.id, + session.room_id, + ) + except PlugNMeetError as exc: + logger.warning( + "[Auto Close Professorless] PlugNMeet end_room failed - session_id=%s room_id=%s error=%s", + session.id, + session.room_id, + str(exc), + ) + except Exception as exc: + logger.warning( + "[Auto Close Professorless] Unexpected end_room error - session_id=%s room_id=%s error=%s", + session.id, + session.room_id, + str(exc), + ) + + self._close_session_locally(session, now) + return 'closed' + + @staticmethod + def _close_session_locally(session: CourseLiveSession, now): + with transaction.atomic(): + updated = CourseLiveSession.objects.filter( + pk=session.pk, + ended_at__isnull=True, + ).update( + ended_at=now, + last_moderator_left_at=None, + auto_close_after_moderator_exit_at=None, + updated_at=now, + ) + if not updated: + return + + LiveSessionUser.objects.filter( + session=session, + is_online=True, + ).update( + is_online=False, + exited_at=now, + updated_at=now, + ) + + @staticmethod + def _clear_pending_auto_close(session: CourseLiveSession): + CourseLiveSession.objects.filter(pk=session.pk).update( + last_moderator_left_at=None, + auto_close_after_moderator_exit_at=None, + updated_at=timezone.now(), + ) diff --git a/apps/course/migrations/0013_courselivesession_auto_close_fields.py b/apps/course/migrations/0013_courselivesession_auto_close_fields.py new file mode 100644 index 0000000..783b920 --- /dev/null +++ b/apps/course/migrations/0013_courselivesession_auto_close_fields.py @@ -0,0 +1,38 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("course", "0012_courselivesession_recording_title"), + ] + + operations = [ + migrations.AddField( + model_name="courselivesession", + name="auto_close_after_moderator_exit_at", + field=models.DateTimeField( + blank=True, + help_text="When the session should be auto-closed if no moderator returns.", + null=True, + verbose_name="Auto Close After Moderator Exit At", + ), + ), + migrations.AddField( + model_name="courselivesession", + name="last_moderator_left_at", + field=models.DateTimeField( + blank=True, + help_text="Timestamp when the last moderator left the live session.", + null=True, + verbose_name="Last Moderator Left At", + ), + ), + migrations.AddIndex( + model_name="courselivesession", + index=models.Index( + fields=["ended_at", "auto_close_after_moderator_exit_at"], + name="course_cour_ended_a_0f47b4_idx", + ), + ), + ] diff --git a/apps/course/models/live_session.py b/apps/course/models/live_session.py index 4157e97..9e67bf4 100644 --- a/apps/course/models/live_session.py +++ b/apps/course/models/live_session.py @@ -36,6 +36,18 @@ class CourseLiveSession(models.Model): null=True, blank=True, ) + last_moderator_left_at = models.DateTimeField( + verbose_name=_("Last Moderator Left At"), + help_text=_("Timestamp when the last moderator left the live session."), + null=True, + blank=True, + ) + auto_close_after_moderator_exit_at = models.DateTimeField( + verbose_name=_("Auto Close After Moderator Exit At"), + help_text=_("When the session should be auto-closed if no moderator returns."), + null=True, + blank=True, + ) recording_title = models.CharField( max_length=255, verbose_name=_("Recording Title"), @@ -64,6 +76,7 @@ class CourseLiveSession(models.Model): models.Index(fields=["course", "started_at"]), models.Index(fields=["course", "created_at"]), models.Index(fields=["room_id"]), + models.Index(fields=["ended_at", "auto_close_after_moderator_exit_at"]), ] diff --git a/apps/course/services/plugnmeet.py b/apps/course/services/plugnmeet.py index 3e88614..c0dc5e1 100644 --- a/apps/course/services/plugnmeet.py +++ b/apps/course/services/plugnmeet.py @@ -40,6 +40,9 @@ class PlugNMeetClient: def is_room_active(self, room_id: str) -> Dict[str, Any]: return self._post("/auth/room/isRoomActive", {"roomId": room_id}) + def end_room(self, room_id: str) -> Dict[str, Any]: + return self._post("/auth/room/endRoom", {"roomId": room_id}) + def get_recording_info(self, record_id: str) -> Dict[str, Any]: """Get detailed information about a recording.""" return self._post("/auth/recording/recordingInfo", {"recordId": record_id}) diff --git a/apps/course/tests/test_webhook_live_session.py b/apps/course/tests/test_webhook_live_session.py index 2401a7e..56ff461 100644 --- a/apps/course/tests/test_webhook_live_session.py +++ b/apps/course/tests/test_webhook_live_session.py @@ -2,7 +2,10 @@ import hashlib import hmac import json import tempfile +from datetime import timedelta +from unittest import mock +from django.core.management import call_command from django.core.files.uploadedfile import SimpleUploadedFile from django.test import override_settings from django.urls import reverse @@ -137,3 +140,62 @@ class PlugNMeetWebhookLiveSessionTests(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) second_session.refresh_from_db() self.assertIsNotNone(second_session.ended_at) + + def test_last_moderator_leave_schedules_auto_close(self): + LiveSessionUser.objects.create( + session=self.session, + user=self.professor, + role='moderator', + entered_at=timezone.now(), + is_online=True, + ) + + response = self._post_webhook( + { + 'event': 'participant_left', + 'room': { + 'name': self.session.room_id, + }, + 'participant': { + 'identity': str(self.professor.id), + }, + } + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.session.refresh_from_db() + self.assertIsNotNone(self.session.last_moderator_left_at) + self.assertIsNotNone(self.session.auto_close_after_moderator_exit_at) + + @mock.patch('apps.course.management.commands.auto_close_professorless_live_sessions.PlugNMeetClient') + def test_auto_close_command_closes_professorless_session(self, mock_client_cls): + LiveSessionUser.objects.create( + session=self.session, + user=self.professor, + role='moderator', + entered_at=timezone.now() - timedelta(minutes=20), + exited_at=timezone.now() - timedelta(minutes=15), + is_online=False, + ) + self.session.last_moderator_left_at = timezone.now() - timedelta(minutes=15) + self.session.auto_close_after_moderator_exit_at = timezone.now() - timedelta(minutes=5) + self.session.save( + update_fields=[ + 'last_moderator_left_at', + 'auto_close_after_moderator_exit_at', + 'updated_at', + ] + ) + + mock_client = mock_client_cls.return_value + mock_client.end_room.return_value = {'status': True, 'msg': 'room ended successfully'} + + call_command('auto_close_professorless_live_sessions') + + self.session.refresh_from_db() + self.assertIsNotNone(self.session.ended_at) + self.assertIsNone(self.session.last_moderator_left_at) + self.assertIsNone(self.session.auto_close_after_moderator_exit_at) + self.assertFalse( + LiveSessionUser.objects.get(session=self.session, user=self.student).is_online + ) diff --git a/apps/course/views/webhook.py b/apps/course/views/webhook.py index da8a2f9..605cca1 100644 --- a/apps/course/views/webhook.py +++ b/apps/course/views/webhook.py @@ -8,6 +8,7 @@ import subprocess import base64 import jwt from typing import Dict, Any, Optional +from datetime import timedelta from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -31,6 +32,12 @@ from utils.exceptions import AppAPIException logger = logging.getLogger(__name__) +AUTO_CLOSE_AFTER_MODERATOR_EXIT_MINUTES = getattr( + settings, + "ONLINE_CLASS_AUTO_CLOSE_AFTER_MODERATOR_EXIT_MINUTES", + 10, +) + class RawJSONParser(BaseParser): """ Parser that preserves the raw body bytes for HMAC signature verification. @@ -251,6 +258,8 @@ class PlugNMeetWebhookAPIView(APIView): 'entered_at': timezone.now() } ) + if role == 'moderator': + self._clear_pending_auto_close(session) logger.info(f"👤 [PlugNMeet Webhook] User {user.id} joined session {session.id} (created: {created})") return {'session_user_id': session_user.id, 'created': created} except Exception as e: @@ -278,6 +287,9 @@ class PlugNMeetWebhookAPIView(APIView): updated = LiveSessionUser.objects.filter( session=session, user=user, is_online=True ).update(is_online=False, exited_at=timezone.now(), updated_at=timezone.now()) + + if updated: + self._update_auto_close_state_after_leave(session, user) logger.info(f"🚪 [PlugNMeet Webhook] User {user.id} left session {session.id} (updated entries count: {updated})") return {'updated': bool(updated)} @@ -411,3 +423,57 @@ class PlugNMeetWebhookAPIView(APIView): except Exception as e: logger.warning(f"⚠️ [PlugNMeet Webhook] Thumbnail failed: {e}") return False + + def _update_auto_close_state_after_leave(self, session: CourseLiveSession, user: User) -> None: + if session.ended_at or not user.can_manage_course(session.course): + return + + has_other_moderator_online = LiveSessionUser.objects.filter( + session=session, + role='moderator', + is_online=True, + ).exists() + if has_other_moderator_online: + self._clear_pending_auto_close(session) + return + + has_non_moderator_online = LiveSessionUser.objects.filter( + session=session, + is_online=True, + ).exclude(role='moderator').exists() + if not has_non_moderator_online: + self._clear_pending_auto_close(session) + return + + now = timezone.now() + due_at = now + timedelta(minutes=AUTO_CLOSE_AFTER_MODERATOR_EXIT_MINUTES) + session.last_moderator_left_at = now + session.auto_close_after_moderator_exit_at = due_at + session.save( + update_fields=[ + 'last_moderator_left_at', + 'auto_close_after_moderator_exit_at', + 'updated_at', + ] + ) + logger.info( + "⏳ [PlugNMeet Webhook] Auto-close countdown scheduled - session_id=%s room_id=%s due_at=%s", + session.id, + session.room_id, + due_at.isoformat(), + ) + + @staticmethod + def _clear_pending_auto_close(session: CourseLiveSession) -> None: + if not session.last_moderator_left_at and not session.auto_close_after_moderator_exit_at: + return + + 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', + ] + )