Files
training-software/training/models.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

205 lines
8.2 KiB
Python

import uuid
from django.conf import settings
from django.db import models
class CompletionMode(models.TextChoices):
ONLINE = "online", "Online"
OFFLINE = "offline", "Offline"
BLENDED = "blended", "Blended"
class CompletionStatus(models.TextChoices):
NOT_STARTED = "not_started", "Not Started"
IN_PROGRESS = "in_progress", "In Progress"
PENDING_SIGNOFF = "pending_signoff", "Pending Trainer Signoff"
COMPLETED = "completed", "Completed"
FAILED = "failed", "Failed"
class SignoffDecision(models.TextChoices):
APPROVED = "approved", "Approved"
REJECTED = "rejected", "Rejected"
class TrainingRecord(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
enrollment = models.OneToOneField("tracking.Enrollment", on_delete=models.CASCADE, related_name="training_record")
completion_mode = models.CharField(max_length=20, choices=CompletionMode.choices, default=CompletionMode.ONLINE)
status = models.CharField(max_length=20, choices=CompletionStatus.choices, default=CompletionStatus.NOT_STARTED)
online_passed_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "training_record"
class TrainerSignoff(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
training_record = models.ForeignKey(TrainingRecord, on_delete=models.CASCADE, related_name="signoffs")
trainer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="given_signoffs")
decision = models.CharField(max_length=20, choices=SignoffDecision.choices)
notes = models.TextField(blank=True)
signed_off_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "training_trainer_signoff"
ordering = ["-signed_off_at"]
class SignoffEvidence(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
training_record = models.ForeignKey(TrainingRecord, on_delete=models.CASCADE, related_name="evidence")
file_name = models.CharField(max_length=255)
file_path = models.CharField(max_length=1000)
uploaded_at = models.DateTimeField(auto_now_add=True)
uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="uploaded_evidence")
class Meta:
db_table = "training_signoff_evidence"
class AttendanceStatus(models.TextChoices):
PRESENT = "present", "Present"
ABSENT = "absent", "Absent"
LATE = "late", "Late"
class Meeting(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
title = models.CharField(max_length=255)
starts_at = models.DateTimeField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "training_meeting"
ordering = ["starts_at", "created_at"]
class MeetingParticipant(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
meeting = models.ForeignKey(Meeting, on_delete=models.CASCADE, related_name="participants")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="meeting_participations")
attendance_status = models.CharField(max_length=20, choices=AttendanceStatus.choices, null=True, blank=True)
attendance_recorded_at = models.DateTimeField(null=True, blank=True)
attendance_recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="recorded_meeting_attendance")
attendance_session = models.ForeignKey(
"attendance.AttendanceSession",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="meeting_attendance_links",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "training_meeting_participant"
constraints = [models.UniqueConstraint(fields=["meeting", "user"], name="training_unique_meeting_participant")]
class CallSessionStatus(models.TextChoices):
ACTIVE = "active", "Active"
ENDED = "ended", "Ended"
class CallSession(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
meeting = models.OneToOneField(Meeting, on_delete=models.CASCADE, related_name="call_session")
status = models.CharField(max_length=20, choices=CallSessionStatus.choices, default=CallSessionStatus.ACTIVE)
started_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name="started_call_sessions",
)
started_at = models.DateTimeField(auto_now_add=True)
ended_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "training_call_session"
class CallParticipantStatus(models.TextChoices):
JOINED = "joined", "Joined"
LEFT = "left", "Left"
class CallParticipant(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
session = models.ForeignKey(CallSession, on_delete=models.CASCADE, related_name="call_participants")
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="call_participations",
)
status = models.CharField(max_length=20, choices=CallParticipantStatus.choices, default=CallParticipantStatus.JOINED)
is_muted = models.BooleanField(default=False)
screen_share_active = models.BooleanField(default=False)
joined_at = models.DateTimeField(auto_now_add=True)
left_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "training_call_participant"
class CallEventType(models.TextChoices):
JOIN = "join", "Join"
LEAVE = "leave", "Leave"
MUTE = "mute", "Mute"
UNMUTE = "unmute", "Unmute"
KICK = "kick", "Kick"
SCREEN_SHARE_START = "screen_share_start", "Screen Share Start"
SCREEN_SHARE_STOP = "screen_share_stop", "Screen Share Stop"
SESSION_STARTED = "session_started", "Session Started"
SESSION_ENDED = "session_ended", "Session Ended"
class CallEvent(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
session = models.ForeignKey(CallSession, on_delete=models.CASCADE, related_name="events")
event_type = models.CharField(max_length=30, choices=CallEventType.choices)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name="call_events_as_actor",
)
target_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="call_events_as_target",
)
metadata = models.JSONField(default=dict, blank=True)
occurred_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "training_call_event"
ordering = ["occurred_at"]
class EmployeeAttendance(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="employee_attendance")
attendance_date = models.DateField()
status = models.CharField(max_length=20, choices=AttendanceStatus.choices)
meeting = models.ForeignKey(Meeting, on_delete=models.SET_NULL, null=True, blank=True, related_name="employee_attendance_entries")
source = models.CharField(max_length=64, default="meeting")
updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="updated_employee_attendance")
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "training_employee_attendance"
constraints = [
models.UniqueConstraint(
fields=["user", "attendance_date", "meeting"],
name="training_unique_employee_attendance_for_meeting",
)
]