Files
training-software/training/services.py
Paperclip CTO dafbfa2bc4 feat(TRA-372): live groupcall backend — WebRTC signaling + session control
- 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>
2026-05-18 14:12:48 +02:00

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