- 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>
205 lines
8.2 KiB
Python
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",
|
|
)
|
|
]
|