Browse Source
feat(course): add live session models, admin and API metadata
feat(course): add live session models, admin and API metadata
- create CourseLiveSession, LiveSessionUser, LiveSessionRecording with indexes and unique constraints - register new models in admin and add UNFOLD navigation entries - update token validation to derive is_online and include livesession_started_at, livesession_ended_at, and can_start_online_class - extend online class entry flow documentation with new fields - add migration for new live session tablesmaster
8 changed files with 529 additions and 13 deletions
-
3apps/course/admin/__init__.py
-
87apps/course/admin/live_session.py
-
104apps/course/migrations/0008_auto_20251013_1724.py
-
3apps/course/models/__init__.py
-
171apps/course/models/live_session.py
-
56apps/course/views/course.py
-
43config/settings/base.py
-
75docs/online_class_entry_flow.md
@ -1,3 +1,4 @@ |
|||||
from .course import * |
from .course import * |
||||
from .lesson import * |
from .lesson import * |
||||
from .participant import * |
|
||||
|
from .participant import * |
||||
|
from .live_session import * |
||||
@ -0,0 +1,87 @@ |
|||||
|
from django.contrib import admin |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
|
||||
|
from unfold.admin import ModelAdmin |
||||
|
from unfold.contrib.filters.admin import ( |
||||
|
ChoicesDropdownFilter, |
||||
|
MultipleRelatedDropdownFilter, |
||||
|
RangeDateFilter, |
||||
|
) |
||||
|
|
||||
|
from utils.admin import project_admin_site |
||||
|
from apps.course.models import ( |
||||
|
CourseLiveSession, |
||||
|
LiveSessionRecording, |
||||
|
LiveSessionUser, |
||||
|
USER_ROLE_CHOICES, |
||||
|
RECORDING_TYPE_CHOICES, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class CourseLiveSessionAdmin(ModelAdmin): |
||||
|
list_display = ("subject", "course", "started_at", "ended_at", "created_at") |
||||
|
list_filter = ( |
||||
|
("course", MultipleRelatedDropdownFilter), |
||||
|
("started_at", RangeDateFilter), |
||||
|
) |
||||
|
search_fields = ("subject", "course__title") |
||||
|
ordering = ("-started_at",) |
||||
|
autocomplete_fields = ("course",) |
||||
|
readonly_fields = ("created_at", "updated_at") |
||||
|
fieldsets = ( |
||||
|
(None, {"fields": ("course", "subject", "started_at", "ended_at")}), |
||||
|
(_("Timestamps"), {"fields": ("created_at", "updated_at")}), |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class LiveSessionUserAdmin(ModelAdmin): |
||||
|
list_display = ("user", "session", "role", "is_online", "entered_at", "exited_at") |
||||
|
list_filter = ( |
||||
|
("session", MultipleRelatedDropdownFilter), |
||||
|
("user", MultipleRelatedDropdownFilter), |
||||
|
("role", ChoicesDropdownFilter), |
||||
|
("entered_at", RangeDateFilter), |
||||
|
("is_online", admin.BooleanFieldListFilter), |
||||
|
) |
||||
|
search_fields = ( |
||||
|
"user__email", |
||||
|
"user__fullname", |
||||
|
"session__subject", |
||||
|
) |
||||
|
autocomplete_fields = ("user", "session") |
||||
|
readonly_fields = ("created_at", "updated_at") |
||||
|
fieldsets = ( |
||||
|
(None, {"fields": ("session", "user", "role")}), |
||||
|
(_("Session Timing"), {"fields": ("entered_at", "exited_at", "is_online")}), |
||||
|
(_("Timestamps"), {"fields": ("created_at", "updated_at")}), |
||||
|
) |
||||
|
|
||||
|
def get_role_choices(self, request): |
||||
|
return USER_ROLE_CHOICES |
||||
|
|
||||
|
|
||||
|
class LiveSessionRecordingAdmin(ModelAdmin): |
||||
|
list_display = ("title", "session", "recording_type", "is_active", "created_at") |
||||
|
list_filter = ( |
||||
|
("session", MultipleRelatedDropdownFilter), |
||||
|
("recording_type", ChoicesDropdownFilter), |
||||
|
("created_at", RangeDateFilter), |
||||
|
("is_active", admin.BooleanFieldListFilter), |
||||
|
) |
||||
|
search_fields = ("title", "session__subject", "session__course__title") |
||||
|
autocomplete_fields = ("session",) |
||||
|
readonly_fields = ("created_at", "updated_at") |
||||
|
fieldsets = ( |
||||
|
(None, {"fields": ("session", "title", "recording_type")}), |
||||
|
(_("Files"), {"fields": ("file", "file_time", "thumbnail")}), |
||||
|
(_("Status"), {"fields": ("is_active",)}), |
||||
|
(_("Timestamps"), {"fields": ("created_at", "updated_at")}), |
||||
|
) |
||||
|
|
||||
|
def get_recording_type_choices(self, request): |
||||
|
return RECORDING_TYPE_CHOICES |
||||
|
|
||||
|
|
||||
|
project_admin_site.register(CourseLiveSession, CourseLiveSessionAdmin) |
||||
|
project_admin_site.register(LiveSessionUser, LiveSessionUserAdmin) |
||||
|
project_admin_site.register(LiveSessionRecording, LiveSessionRecordingAdmin) |
||||
@ -0,0 +1,104 @@ |
|||||
|
# Generated by Django 3.2.4 on 2025-10-13 17:24 |
||||
|
|
||||
|
from django.conf import settings |
||||
|
from django.db import migrations, models |
||||
|
import django.db.models.deletion |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||
|
('course', '0007_alter_course_thumbnail'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='CourseLiveSession', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('subject', models.CharField(help_text='Topic of the live session.', max_length=255, verbose_name='Subject')), |
||||
|
('started_at', models.DateTimeField(help_text='Start time of the live session.', verbose_name='Started At')), |
||||
|
('ended_at', models.DateTimeField(blank=True, help_text='End time of the live session.', null=True, verbose_name='Ended At')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), |
||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), |
||||
|
('course', models.ForeignKey(help_text='Course that this live session belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='live_sessions', to='course.course', verbose_name='Course')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Course Live Session', |
||||
|
'verbose_name_plural': 'Course Live Sessions', |
||||
|
'ordering': ('-started_at', '-id'), |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='LiveSessionUser', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('role', models.CharField(choices=[('participant', 'Participant'), ('moderator', 'Moderator'), ('observer', 'Observer')], help_text='Role of the user in the session', max_length=50, verbose_name='Role')), |
||||
|
('entered_at', models.DateTimeField(help_text='Time the user entered the session', verbose_name='Entered At')), |
||||
|
('exited_at', models.DateTimeField(blank=True, default=None, help_text='Time the user exited the session', null=True, verbose_name='Exited At')), |
||||
|
('is_online', models.BooleanField(default=True, help_text='Is the user currently online?', verbose_name='Is online')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), |
||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), |
||||
|
('session', models.ForeignKey(help_text='Live session that the user joined.', on_delete=django.db.models.deletion.CASCADE, related_name='user_sessions', to='course.courselivesession', verbose_name='Live Session')), |
||||
|
('user', models.ForeignKey(help_text='User participating in the live session.', on_delete=django.db.models.deletion.CASCADE, related_name='live_session_entries', to=settings.AUTH_USER_MODEL, verbose_name='User')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'User Session', |
||||
|
'verbose_name_plural': 'User Sessions', |
||||
|
'ordering': ('-entered_at', '-id'), |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='LiveSessionRecording', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('title', models.CharField(help_text='Title of the recording', max_length=255, verbose_name='Title')), |
||||
|
('file', models.FileField(help_text='File of the recorded session', upload_to='recorded_sessions/', verbose_name='Recording File')), |
||||
|
('file_time', models.DurationField(blank=True, help_text='Duration of the recording file', null=True, verbose_name='File Duration')), |
||||
|
('recording_type', models.CharField(choices=[('voice', 'Voice'), ('video', 'Video')], help_text='Type of the recording (voice or video)', max_length=10, verbose_name='Recording Type')), |
||||
|
('thumbnail', models.ImageField(blank=True, help_text='Thumbnail image for video recordings', null=True, upload_to='recording_thumbnails/', verbose_name='Thumbnail')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Time the recording was created', verbose_name='Created At')), |
||||
|
('updated_at', models.DateTimeField(auto_now=True, help_text='The datetime when the recording was last updated', verbose_name='Updated At')), |
||||
|
('is_active', models.BooleanField(default=True, help_text='Whether this recording is active or not', verbose_name='Is Active')), |
||||
|
('session', models.ForeignKey(help_text='Live session that this recording belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='recordings', to='course.courselivesession', verbose_name='Live Session')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Live Session Recording', |
||||
|
'verbose_name_plural': 'Live Session Recordings', |
||||
|
'ordering': ('-created_at', '-id'), |
||||
|
}, |
||||
|
), |
||||
|
migrations.AddIndex( |
||||
|
model_name='livesessionuser', |
||||
|
index=models.Index(fields=['session', 'user'], name='course_live_session_b1eaa5_idx'), |
||||
|
), |
||||
|
migrations.AddIndex( |
||||
|
model_name='livesessionuser', |
||||
|
index=models.Index(fields=['session', 'is_online'], name='course_live_session_5ef9bc_idx'), |
||||
|
), |
||||
|
migrations.AddIndex( |
||||
|
model_name='livesessionuser', |
||||
|
index=models.Index(fields=['user', 'is_online'], name='course_live_user_id_384830_idx'), |
||||
|
), |
||||
|
migrations.AlterUniqueTogether( |
||||
|
name='livesessionuser', |
||||
|
unique_together={('session', 'user', 'entered_at')}, |
||||
|
), |
||||
|
migrations.AddIndex( |
||||
|
model_name='livesessionrecording', |
||||
|
index=models.Index(fields=['session', 'is_active'], name='course_live_session_f35db0_idx'), |
||||
|
), |
||||
|
migrations.AddIndex( |
||||
|
model_name='livesessionrecording', |
||||
|
index=models.Index(fields=['session', 'recording_type'], name='course_live_session_84b2bf_idx'), |
||||
|
), |
||||
|
migrations.AddIndex( |
||||
|
model_name='courselivesession', |
||||
|
index=models.Index(fields=['course', 'started_at'], name='course_cour_course__b8968b_idx'), |
||||
|
), |
||||
|
migrations.AddIndex( |
||||
|
model_name='courselivesession', |
||||
|
index=models.Index(fields=['course', 'created_at'], name='course_cour_course__142085_idx'), |
||||
|
), |
||||
|
] |
||||
@ -1,3 +1,4 @@ |
|||||
from .course import * |
from .course import * |
||||
from .lesson import * |
from .lesson import * |
||||
from .participant import * |
|
||||
|
from .participant import * |
||||
|
from .live_session import * |
||||
@ -0,0 +1,171 @@ |
|||||
|
from django.db import models |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
|
||||
|
from .course import Course |
||||
|
from apps.account.models import User |
||||
|
|
||||
|
|
||||
|
class CourseLiveSession(models.Model): |
||||
|
course = models.ForeignKey( |
||||
|
Course, |
||||
|
on_delete=models.CASCADE, |
||||
|
related_name="live_sessions", |
||||
|
verbose_name=_("Course"), |
||||
|
help_text=_("Course that this live session belongs to."), |
||||
|
) |
||||
|
subject = models.CharField( |
||||
|
max_length=255, |
||||
|
verbose_name=_("Subject"), |
||||
|
help_text=_("Topic of the live session."), |
||||
|
) |
||||
|
started_at = models.DateTimeField( |
||||
|
verbose_name=_("Started At"), |
||||
|
help_text=_("Start time of the live session."), |
||||
|
) |
||||
|
ended_at = models.DateTimeField( |
||||
|
verbose_name=_("Ended At"), |
||||
|
help_text=_("End time of the live session."), |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
) |
||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) |
||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f"{self.course} - {self.subject}" |
||||
|
|
||||
|
class Meta: |
||||
|
ordering = ("-started_at", "-id") |
||||
|
verbose_name = _("Course Live Session") |
||||
|
verbose_name_plural = _("Course Live Sessions") |
||||
|
indexes = [ |
||||
|
models.Index(fields=["course", "started_at"]), |
||||
|
models.Index(fields=["course", "created_at"]), |
||||
|
] |
||||
|
|
||||
|
|
||||
|
USER_ROLE_CHOICES = ( |
||||
|
("participant", "Participant"), |
||||
|
("moderator", "Moderator"), |
||||
|
("observer", "Observer"), |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class LiveSessionUser(models.Model): |
||||
|
session = models.ForeignKey( |
||||
|
CourseLiveSession, |
||||
|
on_delete=models.CASCADE, |
||||
|
related_name="user_sessions", |
||||
|
verbose_name=_("Live Session"), |
||||
|
help_text=_("Live session that the user joined."), |
||||
|
) |
||||
|
user = models.ForeignKey( |
||||
|
User, |
||||
|
on_delete=models.CASCADE, |
||||
|
related_name="live_session_entries", |
||||
|
verbose_name=_("User"), |
||||
|
help_text=_("User participating in the live session."), |
||||
|
) |
||||
|
role = models.CharField( |
||||
|
max_length=50, |
||||
|
choices=USER_ROLE_CHOICES, |
||||
|
verbose_name=_("Role"), |
||||
|
help_text=_("Role of the user in the session"), |
||||
|
) |
||||
|
entered_at = models.DateTimeField( |
||||
|
verbose_name=_("Entered At"), |
||||
|
help_text=_("Time the user entered the session"), |
||||
|
) |
||||
|
exited_at = models.DateTimeField( |
||||
|
verbose_name=_("Exited At"), |
||||
|
help_text=_("Time the user exited the session"), |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
default=None, |
||||
|
) |
||||
|
is_online = models.BooleanField( |
||||
|
default=True, |
||||
|
verbose_name=_("Is online"), |
||||
|
help_text=_("Is the user currently online?"), |
||||
|
) |
||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) |
||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f"{self.user} @ {self.session}" |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _("User Session") |
||||
|
verbose_name_plural = _("User Sessions") |
||||
|
ordering = ("-entered_at", "-id") |
||||
|
indexes = [ |
||||
|
models.Index(fields=["session", "user"]), |
||||
|
models.Index(fields=["session", "is_online"]), |
||||
|
models.Index(fields=["user", "is_online"]), |
||||
|
] |
||||
|
unique_together = ("session", "user", "entered_at") |
||||
|
|
||||
|
|
||||
|
RECORDING_TYPE_CHOICES = ( |
||||
|
("voice", "Voice"), |
||||
|
("video", "Video"), |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class LiveSessionRecording(models.Model): |
||||
|
session = models.ForeignKey( |
||||
|
CourseLiveSession, |
||||
|
on_delete=models.CASCADE, |
||||
|
related_name="recordings", |
||||
|
verbose_name=_("Live Session"), |
||||
|
help_text=_("Live session that this recording belongs to."), |
||||
|
) |
||||
|
title = models.CharField( |
||||
|
max_length=255, |
||||
|
verbose_name=_("Title"), |
||||
|
help_text=_("Title of the recording"), |
||||
|
) |
||||
|
file = models.FileField( |
||||
|
upload_to="recorded_sessions/", |
||||
|
verbose_name=_("Recording File"), |
||||
|
help_text=_("File of the recorded session"), |
||||
|
) |
||||
|
file_time = models.DurationField( |
||||
|
verbose_name=_("File Duration"), |
||||
|
help_text=_("Duration of the recording file"), |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
) |
||||
|
recording_type = models.CharField( |
||||
|
max_length=10, |
||||
|
choices=RECORDING_TYPE_CHOICES, |
||||
|
verbose_name=_("Recording Type"), |
||||
|
help_text=_("Type of the recording (voice or video)"), |
||||
|
) |
||||
|
thumbnail = models.ImageField( |
||||
|
upload_to="recording_thumbnails/", |
||||
|
verbose_name=_("Thumbnail"), |
||||
|
help_text=_("Thumbnail image for video recordings"), |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
) |
||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"), help_text=_("Time the recording was created")) |
||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"), help_text=_("The datetime when the recording was last updated")) |
||||
|
is_active = models.BooleanField( |
||||
|
default=True, |
||||
|
verbose_name=_("Is Active"), |
||||
|
help_text=_("Whether this recording is active or not"), |
||||
|
) |
||||
|
|
||||
|
def __str__(self): |
||||
|
meet_id = getattr(self.session, "meet_id", self.session_id) |
||||
|
return f"meet:<{meet_id}><{self.id}>{self.title} - {self.recording_type}" |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _("Live Session Recording") |
||||
|
verbose_name_plural = _("Live Session Recordings") |
||||
|
ordering = ("-created_at", "-id") |
||||
|
indexes = [ |
||||
|
models.Index(fields=["session", "is_active"]), |
||||
|
models.Index(fields=["session", "recording_type"]), |
||||
|
] |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue