6 changed files with 309 additions and 0 deletions
-
127apps/course/management/commands/auto_close_professorless_live_sessions.py
-
38apps/course/migrations/0013_courselivesession_auto_close_fields.py
-
13apps/course/models/live_session.py
-
3apps/course/services/plugnmeet.py
-
62apps/course/tests/test_webhook_live_session.py
-
66apps/course/views/webhook.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(), |
||||
|
) |
||||
@ -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", |
||||
|
), |
||||
|
), |
||||
|
] |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue