From 5d108f64a36965970b99f8c4aaacfe91a5c8a420 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 18 Oct 2025 14:32:27 +0330 Subject: [PATCH] feat(course): support live session recordings - add recorded_file FileField to CourseLiveSession with migration - add serializers for recording upload and metadata - add PATCH endpoint to create a recording for latest session: //live-sessions/recorded-file/ - enforce can_manage_course permission and return recording metadata - update urls and views; use FileFieldSerializer for file handling --- .../0010_courselivesession_recorded_file.py | 18 +++++++ apps/course/models/live_session.py | 7 +++ apps/course/serializers/online.py | 25 ++++++++++ apps/course/urls.py | 1 + apps/course/views/live_session.py | 49 ++++++++++++++++++- 5 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 apps/course/migrations/0010_courselivesession_recorded_file.py diff --git a/apps/course/migrations/0010_courselivesession_recorded_file.py b/apps/course/migrations/0010_courselivesession_recorded_file.py new file mode 100644 index 0000000..be623b1 --- /dev/null +++ b/apps/course/migrations/0010_courselivesession_recorded_file.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2025-10-18 12:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0009_auto_20251014_0051'), + ] + + operations = [ + migrations.AddField( + model_name='courselivesession', + name='recorded_file', + field=models.FileField(blank=True, help_text='Recorded file of the live session.', null=True, upload_to='live_session_recordings/', verbose_name='Recorded File'), + ), + ] diff --git a/apps/course/models/live_session.py b/apps/course/models/live_session.py index 06dcccd..e73861b 100644 --- a/apps/course/models/live_session.py +++ b/apps/course/models/live_session.py @@ -36,6 +36,13 @@ class CourseLiveSession(models.Model): null=True, blank=True, ) + recorded_file = models.FileField( + upload_to="live_session_recordings/", + verbose_name=_("Recorded File"), + help_text=_("Recorded file 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")) diff --git a/apps/course/serializers/online.py b/apps/course/serializers/online.py index 031b937..3e89843 100644 --- a/apps/course/serializers/online.py +++ b/apps/course/serializers/online.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from utils import FileFieldSerializer class OnlineClassTokenCreateSerializer(serializers.Serializer): @@ -40,3 +41,27 @@ class LiveSessionTokenSerializer(serializers.Serializer): if not value: raise serializers.ValidationError("course_slug is required.") return value + + +class LiveSessionRecordedFileSerializer(serializers.Serializer): + recorded_file = serializers.FileField(required=True) + + def validate_recorded_file(self, value): + if not value: + raise serializers.ValidationError("recorded_file is required.") + return value + + +class LiveSessionRecordingSerializer(serializers.Serializer): + file = FileFieldSerializer(required=True) + title = serializers.CharField(required=False, max_length=255, allow_blank=True) + recording_type = serializers.ChoiceField(choices=['voice', 'video'], required=False, default='video') + file_time = serializers.DurationField(required=False, allow_null=True) + + def validate_file(self, value): + if not value: + raise serializers.ValidationError("file is required.") + return value + + def validate_title(self, value): + return value.strip() if value else None diff --git a/apps/course/urls.py b/apps/course/urls.py index 33ad058..ee08307 100644 --- a/apps/course/urls.py +++ b/apps/course/urls.py @@ -18,6 +18,7 @@ urlpatterns = [ path('online/token/validate/', views.CourseOnlineClassTokenValidateAPIView.as_view(), name='course-online-token-validate'), path('/online/room/create/', views.CourseLiveSessionRoomCreateAPIView.as_view(), name='course-live-session-room-create'), path('online/room/token/', views.CourseLiveSessionTokenAPIView.as_view(), name='course-live-session-token'), + path('/live-sessions/recorded-file/', views.CourseLiveSessionRecordedFileAPIView.as_view(), name='course-live-session-recorded-file'), # PlugNMeet webhook endpoint path('plugnmeet/webhook/', views.PlugNMeetWebhookAPIView.as_view(), name='plugnmeet-webhook'), diff --git a/apps/course/views/live_session.py b/apps/course/views/live_session.py index 2891259..4d05eae 100644 --- a/apps/course/views/live_session.py +++ b/apps/course/views/live_session.py @@ -9,8 +9,8 @@ from rest_framework.generics import GenericAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from apps.course.models import Course, CourseLiveSession, Participant -from apps.course.serializers import LiveSessionRoomCreateSerializer, LiveSessionTokenSerializer +from apps.course.models import Course, CourseLiveSession, Participant, LiveSessionRecording +from apps.course.serializers import LiveSessionRoomCreateSerializer, LiveSessionTokenSerializer, LiveSessionRecordedFileSerializer, LiveSessionRecordingSerializer from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError from utils.exceptions import AppAPIException @@ -274,3 +274,48 @@ class CourseLiveSessionTokenAPIView(GenericAPIView): if avatar and getattr(avatar, 'url', None): return request.build_absolute_uri(avatar.url) return None + + +class CourseLiveSessionRecordedFileAPIView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = LiveSessionRecordingSerializer + + def patch(self, request, course_id, *args, **kwargs): + logger.info(f"[LiveSession Recorded File] Request from user_id={request.user.id} for course_id={course_id}") + + course = get_object_or_404(Course, id=course_id) + + if not request.user.can_manage_course(course): + logger.warning(f"[LiveSession Recorded File] Permission denied - user_id={request.user.id} course_id={course_id}") + raise AppAPIException({'message': 'You do not have permission to update this course.'}, status_code=status.HTTP_403_FORBIDDEN) + + try: + session = course.live_sessions.latest('-started_at') + except CourseLiveSession.DoesNotExist: + logger.warning(f"[LiveSession Recorded File] No active session found - course_id={course_id} user_id={request.user.id}") + raise AppAPIException({'message': 'No live session found for this course.'}, status_code=status.HTTP_404_NOT_FOUND) + + logger.info(f"[LiveSession Recorded File] Latest session found - session_id={session.id} course_id={course_id}") + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + recording = LiveSessionRecording.objects.create( + session=session, + file=serializer.validated_data['file'], + title=serializer.validated_data.get('title') or f"{session.subject} Recording", + recording_type=serializer.validated_data.get('recording_type', 'video'), + file_time=serializer.validated_data.get('file_time'), + ) + + logger.info(f"[LiveSession Recorded File] Recording created successfully - recording_id={recording.id} session_id={session.id} user_id={request.user.id}") + + return Response({ + 'id': recording.id, + 'session_id': session.id, + 'title': recording.title, + 'file': request.build_absolute_uri(recording.file.url), + 'recording_type': recording.recording_type, + 'file_time': recording.file_time, + 'is_active': recording.is_active, + }, status=status.HTTP_201_CREATED)