- Add Django Channels 4 + channels-redis to requirements and INSTALLED_APPS
- Upgrade ASGI config to ProtocolTypeRouter with JWT-authenticated WebSocket routing
- Add JWTAuthMiddleware for WebSocket token auth via query param
- Add CallSession, CallParticipant, CallEvent models with migration 0004
- MeetingCallConsumer: join/leave, P2P SDP/ICE relay via per-user groups,
instructor mute/unmute/kick with DB audit and WS broadcast
- REST endpoints: GET/POST/DELETE /meetings/{id}/call/ (session lifecycle),
POST /moderate/ (mute/unmute/kick), POST /screen-share/, GET /events/ (audit log)
- IsMeetingModerator permission (accepts training:signoff or meeting:moderate)
- Services: get_or_create_call_session, end_call_session, apply_moderation_action,
toggle_screen_share with full CallEvent audit trail
- Integration tests covering REST lifecycle, moderation, and WebSocket signaling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
202 lines
7.0 KiB
Python
202 lines
7.0 KiB
Python
from __future__ import annotations
|
|
|
|
from django.db.models import Q
|
|
from django.utils.timezone import now
|
|
|
|
from attendance.models import AttendanceSession
|
|
|
|
from .models import (
|
|
CallEvent,
|
|
CallEventType,
|
|
CallParticipant,
|
|
CallParticipantStatus,
|
|
CallSession,
|
|
CallSessionStatus,
|
|
CompletionMode,
|
|
CompletionStatus,
|
|
EmployeeAttendance,
|
|
Meeting,
|
|
MeetingParticipant,
|
|
SignoffDecision,
|
|
SignoffEvidence,
|
|
TrainerSignoff,
|
|
TrainingRecord,
|
|
)
|
|
|
|
|
|
class InvalidTransitionError(Exception):
|
|
pass
|
|
|
|
|
|
def _require_status(record: TrainingRecord, *allowed: str) -> None:
|
|
if record.status not in allowed:
|
|
raise InvalidTransitionError(f"Cannot perform action from status '{record.status}'. Allowed: {', '.join(allowed)}")
|
|
|
|
|
|
def mark_in_progress(record: TrainingRecord) -> TrainingRecord:
|
|
_require_status(record, CompletionStatus.NOT_STARTED)
|
|
record.status = CompletionStatus.IN_PROGRESS
|
|
record.save(update_fields=["status", "updated_at"])
|
|
return record
|
|
|
|
|
|
def mark_online_passed(record: TrainingRecord) -> TrainingRecord:
|
|
_require_status(record, CompletionStatus.IN_PROGRESS)
|
|
record.online_passed_at = now()
|
|
if record.completion_mode == CompletionMode.ONLINE:
|
|
record.status = CompletionStatus.COMPLETED
|
|
record.completed_at = now()
|
|
elif record.completion_mode == CompletionMode.BLENDED:
|
|
record.status = CompletionStatus.PENDING_SIGNOFF
|
|
else:
|
|
raise InvalidTransitionError("mark_online_passed is only valid for online or blended modes.")
|
|
record.save(update_fields=["online_passed_at", "status", "completed_at", "updated_at"])
|
|
return record
|
|
|
|
|
|
def submit_trainer_signoff(record: TrainingRecord, trainer, decision: str, notes: str = "", evidence_paths=None):
|
|
if record.completion_mode == CompletionMode.ONLINE:
|
|
raise InvalidTransitionError("Online-mode training does not require trainer signoff.")
|
|
if record.completion_mode == CompletionMode.OFFLINE:
|
|
_require_status(record, CompletionStatus.IN_PROGRESS, CompletionStatus.PENDING_SIGNOFF)
|
|
else:
|
|
_require_status(record, CompletionStatus.PENDING_SIGNOFF)
|
|
|
|
signoff = TrainerSignoff.objects.create(training_record=record, trainer=trainer, decision=decision, notes=notes)
|
|
|
|
if evidence_paths:
|
|
SignoffEvidence.objects.bulk_create([
|
|
SignoffEvidence(training_record=record, file_name=fname, file_path=fpath, uploaded_by=trainer)
|
|
for fname, fpath in evidence_paths
|
|
])
|
|
|
|
if decision == SignoffDecision.APPROVED:
|
|
record.status = CompletionStatus.COMPLETED
|
|
record.completed_at = now()
|
|
record.save(update_fields=["status", "completed_at", "updated_at"])
|
|
else:
|
|
record.status = CompletionStatus.IN_PROGRESS
|
|
record.save(update_fields=["status", "updated_at"])
|
|
|
|
return signoff
|
|
|
|
|
|
def _find_linked_attendance_session(meeting: Meeting, user_id):
|
|
return (
|
|
AttendanceSession.objects.filter(user_id=user_id)
|
|
.filter(checked_in_at__date=meeting.starts_at.date())
|
|
.filter(Q(checked_out_at__isnull=True) | Q(checked_out_at__gte=meeting.starts_at))
|
|
.order_by("-checked_in_at")
|
|
.first()
|
|
)
|
|
|
|
|
|
def record_meeting_attendance(meeting: Meeting, *, user_id, status_value: str, actor):
|
|
participant = MeetingParticipant.objects.get(meeting=meeting, user_id=user_id)
|
|
participant.attendance_status = status_value
|
|
participant.attendance_recorded_at = now()
|
|
participant.attendance_recorded_by = actor
|
|
participant.attendance_session = _find_linked_attendance_session(meeting, user_id)
|
|
participant.save(update_fields=["attendance_status", "attendance_recorded_at", "attendance_recorded_by", "attendance_session"])
|
|
|
|
EmployeeAttendance.objects.update_or_create(
|
|
user_id=user_id,
|
|
attendance_date=meeting.starts_at.date(),
|
|
meeting=meeting,
|
|
defaults={"status": status_value, "source": "meeting", "updated_by": actor},
|
|
)
|
|
return participant
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Call session management
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class CallSessionError(Exception):
|
|
pass
|
|
|
|
|
|
def get_or_create_call_session(meeting: Meeting, actor) -> CallSession:
|
|
session, _ = CallSession.objects.get_or_create(
|
|
meeting=meeting,
|
|
defaults={"started_by": actor, "status": CallSessionStatus.ACTIVE},
|
|
)
|
|
if session.status == CallSessionStatus.ENDED:
|
|
raise CallSessionError("This meeting's call session has already ended.")
|
|
return session
|
|
|
|
|
|
def end_call_session(session: CallSession, actor) -> CallSession:
|
|
if session.status == CallSessionStatus.ENDED:
|
|
raise CallSessionError("Call session is already ended.")
|
|
session.status = CallSessionStatus.ENDED
|
|
session.ended_at = now()
|
|
session.save(update_fields=["status", "ended_at"])
|
|
|
|
CallParticipant.objects.filter(
|
|
session=session,
|
|
status=CallParticipantStatus.JOINED,
|
|
).update(status=CallParticipantStatus.LEFT, left_at=now())
|
|
|
|
CallEvent.objects.create(
|
|
session=session,
|
|
event_type=CallEventType.SESSION_ENDED,
|
|
actor=actor,
|
|
)
|
|
return session
|
|
|
|
|
|
def apply_moderation_action(session: CallSession, action: str, target_user_id, actor) -> None:
|
|
from django.contrib.auth import get_user_model
|
|
|
|
User = get_user_model()
|
|
|
|
try:
|
|
target = User.objects.get(pk=target_user_id)
|
|
except User.DoesNotExist:
|
|
raise CallSessionError("Target user not found.")
|
|
|
|
if action == "kick":
|
|
CallParticipant.objects.filter(
|
|
session=session,
|
|
user=target,
|
|
status=CallParticipantStatus.JOINED,
|
|
).update(status=CallParticipantStatus.LEFT, left_at=now())
|
|
CallEvent.objects.create(
|
|
session=session,
|
|
event_type=CallEventType.KICK,
|
|
actor=actor,
|
|
target_user=target,
|
|
)
|
|
elif action in ("mute", "unmute"):
|
|
is_muted = action == "mute"
|
|
CallParticipant.objects.filter(
|
|
session=session,
|
|
user=target,
|
|
status=CallParticipantStatus.JOINED,
|
|
).update(is_muted=is_muted)
|
|
event_type = CallEventType.MUTE if is_muted else CallEventType.UNMUTE
|
|
CallEvent.objects.create(
|
|
session=session,
|
|
event_type=event_type,
|
|
actor=actor,
|
|
target_user=target,
|
|
)
|
|
else:
|
|
raise CallSessionError(f"Unknown action: {action}")
|
|
|
|
|
|
def toggle_screen_share(session: CallSession, actor, *, start: bool) -> CallParticipant:
|
|
"""Start or stop the actor's own screen share. Caller must hold meeting:moderate."""
|
|
cp, _ = CallParticipant.objects.get_or_create(
|
|
session=session,
|
|
user=actor,
|
|
defaults={"status": CallParticipantStatus.JOINED},
|
|
)
|
|
cp.screen_share_active = start
|
|
cp.save(update_fields=["screen_share_active"])
|
|
event_type = CallEventType.SCREEN_SHARE_START if start else CallEventType.SCREEN_SHARE_STOP
|
|
CallEvent.objects.create(session=session, event_type=event_type, actor=actor)
|
|
return cp
|