Browse Source

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 tables
master
mortezaei 7 months ago
parent
commit
24326683aa
  1. 3
      apps/course/admin/__init__.py
  2. 87
      apps/course/admin/live_session.py
  3. 104
      apps/course/migrations/0008_auto_20251013_1724.py
  4. 3
      apps/course/models/__init__.py
  5. 171
      apps/course/models/live_session.py
  6. 56
      apps/course/views/course.py
  7. 43
      config/settings/base.py
  8. 75
      docs/online_class_entry_flow.md

3
apps/course/admin/__init__.py

@ -1,3 +1,4 @@
from .course import *
from .lesson import *
from .participant import *
from .participant import *
from .live_session import *

87
apps/course/admin/live_session.py

@ -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)

104
apps/course/migrations/0008_auto_20251013_1724.py

@ -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'),
),
]

3
apps/course/models/__init__.py

@ -1,3 +1,4 @@
from .course import *
from .lesson import *
from .participant import *
from .participant import *
from .live_session import *

171
apps/course/models/live_session.py

@ -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"]),
]

56
apps/course/views/course.py

@ -20,7 +20,14 @@ from apps.course.serializers import (
CourseAttachmentSerializer, CourseGlossarySerializer, MyCourseListSerializer,
OnlineClassTokenCreateSerializer, OnlineClassTokenVerifySerializer
)
from apps.course.models import Course, CourseCategory, CourseAttachment, CourseGlossary, Participant
from apps.course.models import (
Course,
CourseAttachment,
CourseCategory,
CourseGlossary,
CourseLiveSession,
Participant,
)
from apps.course.doc import *
from apps.account.serializers import UserProfileSerializer
from utils.exceptions import AppAPIException
@ -407,14 +414,57 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
status_value = course.status
has_started = status_value in [Course.StatusChoices.ONGOING, Course.StatusChoices.FINISHED]
timing_data = course.timing if isinstance(course.timing, dict) else {}
return {
user_id = payload.get('user_id')
can_start_online_class = course.professor_id == user_id
metadata = {
'status': status_value,
'is_online': course.is_online,
'has_started': has_started,
'has_finished': status_value == Course.StatusChoices.FINISHED,
'professor_in_class': payload.get('extra', {}).get('professor_in_class', False),
'can_start_online_class': can_start_online_class,
'scheduled_times': timing_data,
'generated_at': payload.get('generated_at'),
'validated_at': timezone.now().isoformat(),
}
metadata.update(self._resolve_live_session_timings(course, payload))
return metadata
def _resolve_live_session_timings(self, course: Course, payload: dict) -> dict:
latest_session = (
CourseLiveSession.objects.filter(course=course)
.order_by('-started_at')
.first()
)
started_at = None
if latest_session and latest_session.started_at:
started_at = latest_session.started_at
else:
started_at = payload.get('generated_at')
ended_at = None
if latest_session and latest_session.ended_at:
ended_at = latest_session.ended_at
elif started_at:
ended_at = timezone.now()
is_online = False
if latest_session and latest_session.started_at and not latest_session.ended_at:
is_online = True
return {
'is_online': is_online,
'livesession_started_at': self._format_datetime(started_at),
'livesession_ended_at': self._format_datetime(ended_at),
}
@staticmethod
def _format_datetime(value):
if not value:
return None
if isinstance(value, str):
return value
if timezone.is_naive(value):
value = timezone.make_aware(value, timezone.get_current_timezone())
return timezone.localtime(value).isoformat()

43
config/settings/base.py

@ -494,6 +494,34 @@ UNFOLD = {
],
},
{
"page": "course_online",
"models": [
"course.courselivesession",
"course.livesessionuser",
"course.livesessionrecording",
],
"items": [
{
"title": _("Course Onlines"),
"icon": "video_call",
"link": reverse_lazy("admin:course_courselivesession_changelist"),
"active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_courselivesession_changelist"))),
},
{
"title": _("Session Users"),
"icon": "groups",
"link": reverse_lazy("admin:course_livesessionuser_changelist"),
"active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_livesessionuser_changelist"))),
},
{
"title": _("Session Recordings"),
"icon": "play_circle",
"link": reverse_lazy("admin:course_livesessionrecording_changelist"),
"active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_livesessionrecording_changelist"))),
},
],
},
],
"SIDEBAR": {
"show_search": True,
@ -598,6 +626,21 @@ UNFOLD = {
"icon": "book",
"link": reverse_lazy("admin:course_glossary_changelist"),
},
{
"title": _("Live Sessions"),
"icon": "video_call",
"link": reverse_lazy("admin:course_courselivesession_changelist"),
},
{
"title": _("Session Users"),
"icon": "groups",
"link": reverse_lazy("admin:course_livesessionuser_changelist"),
},
{
"title": _("Session Recordings"),
"icon": "play_circle",
"link": reverse_lazy("admin:course_livesessionrecording_changelist"),
},
{
"title": _("Certificates"),
"icon": "workspace_premium",

75
docs/online_class_entry_flow.md

@ -50,22 +50,70 @@ curl --request POST \
"course": {
"id": 42,
"title": "درس اخلاق",
"slug": "dars-akhlagh",
"category": {
"name": "علوم اسلامی",
"slug": "islamic-sciences",
"course_count": 15
},
"access": true,
"participant_count": 85,
"professor": {
"id": 7,
"fullname": "استاد رضایی",
"slug": "rezaei",
"avatar": "https://api.example.com/media/avatars/rezaei.jpg",
"email": "rezaei@example.com",
"phone_number": "+1234567890",
"info": "استاد دانشگاه",
"skill": "فقه و اصول",
"city": "قم",
"country": "ایران",
"birthdate": "1975-05-15",
"gender": "male"
},
"is_professor": false,
"thumbnail": "https://api.example.com/media/courses/akhlagh.jpg",
"video_type": "link",
"video_file": null,
"video_link": "https://example.com/intro-video.mp4",
"is_online": true,
"online_link": "https://meeting.example.com/class/42",
"level": "intermediate",
"description": "دوره جامع اخلاق اسلامی...",
"duration": "3 ماه",
"lessons_count": 12,
"lessons_complated_count": 5,
"short_description": "آشنایی با مبانی اخلاق اسلامی",
"status": "ongoing",
"is_free": false,
"price": "50000.00",
"discount_percentage": 10,
"final_price": "45000.00",
"timing": {
"monday": "18:00",
"wednesday": "18:00"
},
"online_link": "https://meeting.example.com/class/42",
"is_online": true,
"professor": {
"id": 7,
"fullname": "استاد رضایی"
}
"features": ["ضبط جلسات", "گواهینامه معتبر", "پشتیبانی 24 ساعته"],
"last_lesson_id": 6,
"room_id": 123,
"user_transaction_status": "approved"
},
"user": {
"id": 105,
"device_id": "device-xyz-123",
"fcm": "fcm-token-abc",
"fullname": "علی احمدی",
"email": "ali@example.com"
"slug": "ali-ahmadi",
"avatar": "https://api.example.com/media/avatars/ali.jpg",
"email": "ali@example.com",
"phone_number": "+9876543210",
"info": "دانشجو",
"skill": "برنامه‌نویسی",
"city": "تهران",
"country": "ایران",
"birthdate": "1995-08-20",
"gender": "male"
},
"metadata": {
"status": "ongoing",
@ -73,16 +121,27 @@ curl --request POST \
"has_started": true,
"has_finished": false,
"professor_in_class": false,
"can_start_online_class": false,
"scheduled_times": {
"monday": "18:00",
"wednesday": "18:00"
},
"generated_at": "2024-10-05T10:15:30Z",
"validated_at": "2024-10-05T10:16:05.123456Z"
"validated_at": "2024-10-05T10:16:05.123456Z",
"livesession_started_at": "2024-10-05T10:15:30Z",
}
}
```
**توضیحات فیلدهای مهم:**
- **course**: شامل تمام اطلاعات دوره شامل استاد، دسته‌بندی، درس‌ها، قیمت و...
- **user**: پروفایل کامل کاربری که توکن را اعتبارسنجی کرده
- **metadata.can_start_online_class**: `true` برای استاد دوره، `false` برای دانشجویان (فقط استاد می‌تواند کلاس را شروع کند)
- **metadata.professor_in_class**: نشان می‌دهد استاد در حال حاضر در کلاس حضور دارد یا خیر
- **metadata.has_started**: دوره شروع شده است (`ongoing` یا `finished`)
- **metadata.has_finished**: دوره به پایان رسیده است (`finished`)
## نکات پیاده‌سازی در فرانت‌اند
1. پس از دریافت پاسخ مرحله‌ٔ اول، کاربر را به `url` بازگردانی کنید.

Loading…
Cancel
Save