Browse Source

cron job codes added for checking professors attendence and auto close room

master
Mohsen Taba 1 month ago
parent
commit
d3d4b2d036
  1. 127
      apps/course/management/commands/auto_close_professorless_live_sessions.py
  2. 38
      apps/course/migrations/0013_courselivesession_auto_close_fields.py
  3. 13
      apps/course/models/live_session.py
  4. 3
      apps/course/services/plugnmeet.py
  5. 62
      apps/course/tests/test_webhook_live_session.py
  6. 66
      apps/course/views/webhook.py

127
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(),
)

38
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",
),
),
]

13
apps/course/models/live_session.py

@ -36,6 +36,18 @@ class CourseLiveSession(models.Model):
null=True, null=True,
blank=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( recording_title = models.CharField(
max_length=255, max_length=255,
verbose_name=_("Recording Title"), verbose_name=_("Recording Title"),
@ -64,6 +76,7 @@ class CourseLiveSession(models.Model):
models.Index(fields=["course", "started_at"]), models.Index(fields=["course", "started_at"]),
models.Index(fields=["course", "created_at"]), models.Index(fields=["course", "created_at"]),
models.Index(fields=["room_id"]), models.Index(fields=["room_id"]),
models.Index(fields=["ended_at", "auto_close_after_moderator_exit_at"]),
] ]

3
apps/course/services/plugnmeet.py

@ -40,6 +40,9 @@ class PlugNMeetClient:
def is_room_active(self, room_id: str) -> Dict[str, Any]: def is_room_active(self, room_id: str) -> Dict[str, Any]:
return self._post("/auth/room/isRoomActive", {"roomId": room_id}) 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]: def get_recording_info(self, record_id: str) -> Dict[str, Any]:
"""Get detailed information about a recording.""" """Get detailed information about a recording."""
return self._post("/auth/recording/recordingInfo", {"recordId": record_id}) return self._post("/auth/recording/recordingInfo", {"recordId": record_id})

62
apps/course/tests/test_webhook_live_session.py

@ -2,7 +2,10 @@ import hashlib
import hmac import hmac
import json import json
import tempfile 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.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings from django.test import override_settings
from django.urls import reverse from django.urls import reverse
@ -137,3 +140,62 @@ class PlugNMeetWebhookLiveSessionTests(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
second_session.refresh_from_db() second_session.refresh_from_db()
self.assertIsNotNone(second_session.ended_at) 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
)

66
apps/course/views/webhook.py

@ -8,6 +8,7 @@ import subprocess
import base64 import base64
import jwt import jwt
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
@ -31,6 +32,12 @@ from utils.exceptions import AppAPIException
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
AUTO_CLOSE_AFTER_MODERATOR_EXIT_MINUTES = getattr(
settings,
"ONLINE_CLASS_AUTO_CLOSE_AFTER_MODERATOR_EXIT_MINUTES",
10,
)
class RawJSONParser(BaseParser): class RawJSONParser(BaseParser):
""" """
Parser that preserves the raw body bytes for HMAC signature verification. Parser that preserves the raw body bytes for HMAC signature verification.
@ -251,6 +258,8 @@ class PlugNMeetWebhookAPIView(APIView):
'entered_at': timezone.now() '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})") logger.info(f"👤 [PlugNMeet Webhook] User {user.id} joined session {session.id} (created: {created})")
return {'session_user_id': session_user.id, 'created': created} return {'session_user_id': session_user.id, 'created': created}
except Exception as e: except Exception as e:
@ -278,6 +287,9 @@ class PlugNMeetWebhookAPIView(APIView):
updated = LiveSessionUser.objects.filter( updated = LiveSessionUser.objects.filter(
session=session, user=user, is_online=True session=session, user=user, is_online=True
).update(is_online=False, exited_at=timezone.now(), updated_at=timezone.now()) ).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})") logger.info(f"🚪 [PlugNMeet Webhook] User {user.id} left session {session.id} (updated entries count: {updated})")
return {'updated': bool(updated)} return {'updated': bool(updated)}
@ -411,3 +423,57 @@ class PlugNMeetWebhookAPIView(APIView):
except Exception as e: except Exception as e:
logger.warning(f"⚠️ [PlugNMeet Webhook] Thumbnail failed: {e}") logger.warning(f"⚠️ [PlugNMeet Webhook] Thumbnail failed: {e}")
return False 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',
]
)
Loading…
Cancel
Save