From 24326683aaea094856781ac255955844abec2f17 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Mon, 13 Oct 2025 19:05:38 +0330 Subject: [PATCH] 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 --- apps/course/admin/__init__.py | 3 +- apps/course/admin/live_session.py | 87 +++++++++ .../migrations/0008_auto_20251013_1724.py | 104 +++++++++++ apps/course/models/__init__.py | 3 +- apps/course/models/live_session.py | 171 ++++++++++++++++++ apps/course/views/course.py | 56 +++++- config/settings/base.py | 43 +++++ docs/online_class_entry_flow.md | 75 +++++++- 8 files changed, 529 insertions(+), 13 deletions(-) create mode 100644 apps/course/admin/live_session.py create mode 100644 apps/course/migrations/0008_auto_20251013_1724.py create mode 100644 apps/course/models/live_session.py diff --git a/apps/course/admin/__init__.py b/apps/course/admin/__init__.py index e86b7ee..46978fc 100644 --- a/apps/course/admin/__init__.py +++ b/apps/course/admin/__init__.py @@ -1,3 +1,4 @@ from .course import * from .lesson import * -from .participant import * \ No newline at end of file +from .participant import * +from .live_session import * \ No newline at end of file diff --git a/apps/course/admin/live_session.py b/apps/course/admin/live_session.py new file mode 100644 index 0000000..5f2b136 --- /dev/null +++ b/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) diff --git a/apps/course/migrations/0008_auto_20251013_1724.py b/apps/course/migrations/0008_auto_20251013_1724.py new file mode 100644 index 0000000..ae55647 --- /dev/null +++ b/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'), + ), + ] diff --git a/apps/course/models/__init__.py b/apps/course/models/__init__.py index e86b7ee..46978fc 100644 --- a/apps/course/models/__init__.py +++ b/apps/course/models/__init__.py @@ -1,3 +1,4 @@ from .course import * from .lesson import * -from .participant import * \ No newline at end of file +from .participant import * +from .live_session import * \ No newline at end of file diff --git a/apps/course/models/live_session.py b/apps/course/models/live_session.py new file mode 100644 index 0000000..cd31bf8 --- /dev/null +++ b/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"]), + ] diff --git a/apps/course/views/course.py b/apps/course/views/course.py index 35ba5e2..d242766 100644 --- a/apps/course/views/course.py +++ b/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() \ No newline at end of file diff --git a/config/settings/base.py b/config/settings/base.py index 97d28bd..6970bfa 100755 --- a/config/settings/base.py +++ b/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", diff --git a/docs/online_class_entry_flow.md b/docs/online_class_entry_flow.md index 75fcc7f..34c091a 100644 --- a/docs/online_class_entry_flow.md +++ b/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` بازگردانی کنید.