import logging from django.db import transaction from django.db.models import Q from django.utils import timezone from rest_framework import serializers, viewsets from rest_framework.permissions import IsAuthenticated from rest_framework.authentication import TokenAuthentication from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.exceptions import PermissionDenied from utils.pagination import StandardResultsSetPagination from apps.account.permissions import IsPanelUser, IsSuperAdminOrReadOnlyForProfessor from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError from apps.course.services.web_egress import stop_session_web_egress_if_active from apps.course.models import ( Course, CourseCategory, CourseChapter, CourseLesson, Lesson, CourseAttachment, Attachment, CourseGlossary, Glossary, CourseLiveSession, LiveSessionUser, LiveSessionRecording ) from apps.course.serializers.admin import ( AdminCourseCategorySerializer, AdminCourseListSerializer, AdminCourseDetailSerializer, AdminCourseChapterSerializer, AdminCourseLessonSerializer, AdminCourseAttachmentSerializer, AdminCourseGlossarySerializer, AdminLessonSerializer, AdminGlossarySerializer, AdminAttachmentSerializer, AdminLiveSessionListSerializer, AdminLiveSessionDetailSerializer ) from utils import FileFieldSerializer logger = logging.getLogger(__name__) def is_professor(request): return getattr(request.user, 'user_type', None) == 'professor' class AdminCourseViewSet(viewsets.ModelViewSet): """ Admin CRUD ViewSet for Course management. Professors can only access/modify their own courses. """ permission_classes = [IsAuthenticated, IsPanelUser] authentication_classes = [TokenAuthentication] pagination_class = StandardResultsSetPagination def get_serializer_class(self): if self.action == 'list': return AdminCourseListSerializer return AdminCourseDetailSerializer def get_queryset(self): queryset = Course.objects.all().select_related('category', 'professor') # Professors can only see their own courses if is_professor(self.request): queryset = queryset.filter(professor_id=self.request.user.id) # Search Query search_query = self.request.query_params.get('search', None) has_search = False if search_query: has_search = True from django.db.models import Case, When, Value, IntegerField queryset = queryset.filter( Q(title__icontains=search_query) | Q(description__icontains=search_query) | Q(short_description__icontains=search_query) ).annotate( search_relevance=Case( When(title__icontains=search_query, then=Value(2)), default=Value(1), output_field=IntegerField() ) ) # Filters category_id = self.request.query_params.get('category', None) if category_id: queryset = queryset.filter(category_id=category_id) level = self.request.query_params.get('level', None) if level: queryset = queryset.filter(level__contains=[{"title": level}]) status_param = self.request.query_params.get('status', None) if status_param: queryset = queryset.filter(status__contains=[{"title": status_param}]) is_free_param = self.request.query_params.get('is_free', None) if is_free_param is not None: if is_free_param.lower() == 'true': queryset = queryset.filter(is_free=True) elif is_free_param.lower() == 'false': queryset = queryset.filter(is_free=False) student_id = self.request.query_params.get('student_id', None) if student_id: queryset = queryset.filter(participants__student_id=student_id) if has_search: return queryset.order_by('-search_relevance', '-id') return queryset.order_by('-id') def perform_create(self, serializer): # If the logged-in user is a professor, force the professor field to themselves if is_professor(self.request): serializer.save(professor_id=self.request.user.id) else: serializer.save() def perform_update(self, serializer): # Prevent professors from reassigning a course to another professor if is_professor(self.request): instance = self.get_object() if instance.professor_id != self.request.user.id: raise PermissionDenied("You can only edit your own courses.") serializer.save(professor_id=self.request.user.id) else: serializer.save() class AdminChapterViewSet(viewsets.ModelViewSet): """ Admin CRUD ViewSet for Course Chapters. Professors can only access chapters of their own courses. """ permission_classes = [IsAuthenticated, IsPanelUser] authentication_classes = [TokenAuthentication] serializer_class = AdminCourseChapterSerializer pagination_class = None def get_queryset(self): queryset = CourseChapter.objects.all() # Professors can only see chapters of their own courses if is_professor(self.request): queryset = queryset.filter(course__professor_id=self.request.user.id) course_id = self.request.query_params.get('course', None) if course_id: queryset = queryset.filter(course_id=course_id) return queryset.order_by('priority', 'id') @action(detail=False, methods=['post']) def reorder(self, request): """ Reorders chapters in bulk. Format: {"chapter_ids": [id1, id2, ...]} """ chapter_ids = request.data.get('chapter_ids', []) if not chapter_ids: return Response({'error': 'chapter_ids list is required'}, status=400) from django.db import transaction with transaction.atomic(): for idx, c_id in enumerate(chapter_ids): CourseChapter.objects.filter(id=c_id).update(priority=idx + 1) return Response({'status': 'success'}) class AdminCourseLessonViewSet(viewsets.ModelViewSet): """ Admin CRUD ViewSet for Lessons associated with a Course/Chapter. Professors can only access lessons in their own courses. """ permission_classes = [IsAuthenticated, IsPanelUser] authentication_classes = [TokenAuthentication] serializer_class = AdminCourseLessonSerializer pagination_class = None def get_queryset(self): queryset = CourseLesson.objects.all().select_related('lesson', 'chapter') # Professors can only see lessons of their own courses if is_professor(self.request): queryset = queryset.filter(course__professor_id=self.request.user.id) course_id = self.request.query_params.get('course', None) if course_id: queryset = queryset.filter(course_id=course_id) chapter_id = self.request.query_params.get('chapter', None) if chapter_id: queryset = queryset.filter(chapter_id=chapter_id) return queryset.order_by('priority', 'id') def perform_destroy(self, instance): # Prevent orphans: delete the base Lesson object along with the junction model lesson = instance.lesson instance.delete() if lesson: lesson.delete() @action(detail=False, methods=['post']) def reorder(self, request): """ Reorders lessons in bulk. Format: {"lesson_ids": [id1, id2, ...]} """ lesson_ids = request.data.get('lesson_ids', []) if not lesson_ids: return Response({'error': 'lesson_ids list is required'}, status=400) from django.db import transaction with transaction.atomic(): for idx, l_id in enumerate(lesson_ids): CourseLesson.objects.filter(id=l_id).update(priority=idx + 1) return Response({'status': 'success'}) class AdminCourseAttachmentViewSet(viewsets.ModelViewSet): """ Admin CRUD ViewSet for Course Attachments. Professors can only access attachments of their own courses. """ permission_classes = [IsAuthenticated, IsPanelUser] authentication_classes = [TokenAuthentication] serializer_class = AdminCourseAttachmentSerializer pagination_class = None def get_queryset(self): queryset = CourseAttachment.objects.all().select_related('attachment') # Professors can only see attachments of their own courses if is_professor(self.request): queryset = queryset.filter(course__professor_id=self.request.user.id) course_id = self.request.query_params.get('course', None) if course_id: queryset = queryset.filter(course_id=course_id) return queryset.order_by('-id') def create(self, request, *args, **kwargs): attachment_ids = request.data.get('attachment_ids') new_attachments = request.data.get('new_attachments') course_id = request.data.get('course') if not course_id or (not attachment_ids and not new_attachments): return super().create(request, *args, **kwargs) if is_professor(request): course = Course.objects.filter(id=course_id, professor_id=request.user.id).first() else: course = Course.objects.filter(id=course_id).first() if not course: raise PermissionDenied("Course not found or access denied.") created_links = [] seen_attachment_ids = set() file_field = FileFieldSerializer() file_field.bind('file', None) with transaction.atomic(): for attachment_id in attachment_ids or []: try: parsed_id = int(attachment_id) except (TypeError, ValueError): continue if parsed_id in seen_attachment_ids: continue seen_attachment_ids.add(parsed_id) attachment = Attachment.objects.filter(pk=parsed_id).first() if not attachment: continue course_attachment, created = CourseAttachment.objects.get_or_create( course=course, attachment=attachment, ) if created: created_links.append(course_attachment) for item in new_attachments or []: title = item.get('title') file_data = item.get('file') if not title or not file_data: continue try: normalized_file = file_field.to_internal_value(file_data) except serializers.ValidationError as exc: raise serializers.ValidationError({ 'new_attachments': [exc.detail] }) attachment = Attachment.objects.create( title=title, file=normalized_file, ) created_links.append( CourseAttachment.objects.create( course=course, attachment=attachment, ) ) serializer = self.get_serializer(created_links, many=True) return Response(serializer.data, status=201) def perform_destroy(self, instance): # Prevent orphans: delete base Attachment along with junction model attachment = instance.attachment instance.delete() if attachment: attachment.delete() class AdminCourseGlossaryViewSet(viewsets.ModelViewSet): """ Admin CRUD ViewSet for Course Glossary terms. Professors can only access glossaries of their own courses. """ permission_classes = [IsAuthenticated, IsPanelUser] authentication_classes = [TokenAuthentication] serializer_class = AdminCourseGlossarySerializer pagination_class = None def get_queryset(self): queryset = CourseGlossary.objects.all().select_related('glossary') # Professors can only see glossaries of their own courses if is_professor(self.request): queryset = queryset.filter(course__professor_id=self.request.user.id) course_id = self.request.query_params.get('course', None) if course_id: queryset = queryset.filter(course_id=course_id) return queryset.order_by('-id') def create(self, request, *args, **kwargs): glossary_ids = request.data.get('glossary_ids') new_glossaries = request.data.get('new_glossaries') course_id = request.data.get('course') if not course_id or (not glossary_ids and not new_glossaries): return super().create(request, *args, **kwargs) if is_professor(request): course = Course.objects.filter(id=course_id, professor_id=request.user.id).first() else: course = Course.objects.filter(id=course_id).first() if not course: raise PermissionDenied("Course not found or access denied.") created_links = [] seen_glossary_ids = set() with transaction.atomic(): for glossary_id in glossary_ids or []: try: parsed_id = int(glossary_id) except (TypeError, ValueError): continue if parsed_id in seen_glossary_ids: continue seen_glossary_ids.add(parsed_id) glossary = Glossary.objects.filter(pk=parsed_id).first() if not glossary: continue course_glossary, created = CourseGlossary.objects.get_or_create( course=course, glossary=glossary, ) if created: created_links.append(course_glossary) for item in new_glossaries or []: title = item.get('title') description = item.get('description') if not title or not description: continue glossary = Glossary.objects.create( title=title, description=description, ) created_links.append( CourseGlossary.objects.create( course=course, glossary=glossary, ) ) serializer = self.get_serializer(created_links, many=True) return Response(serializer.data, status=201) def perform_destroy(self, instance): # Prevent orphans: delete base Glossary along with junction model glossary = instance.glossary instance.delete() if glossary: glossary.delete() class AdminCourseCategoryViewSet(viewsets.ModelViewSet): """ Admin CRUD ViewSet for Course Categories. Professors have read-only access. """ permission_classes = [IsAuthenticated, IsSuperAdminOrReadOnlyForProfessor] authentication_classes = [TokenAuthentication] serializer_class = AdminCourseCategorySerializer pagination_class = StandardResultsSetPagination def get_queryset(self): queryset = CourseCategory.objects.all().order_by('-id') search = self.request.query_params.get('search', None) if search: queryset = queryset.filter(Q(name__icontains=search) | Q(slug__icontains=search)) return queryset class AdminLessonViewSet(viewsets.ModelViewSet): """ Admin CRUD ViewSet for base Lesson (Repository). Professors can only see lessons linked to their own courses. """ permission_classes = [IsAuthenticated, IsPanelUser] authentication_classes = [TokenAuthentication] serializer_class = AdminLessonSerializer pagination_class = StandardResultsSetPagination def get_queryset(self): queryset = Lesson.objects.all().order_by('-id') search = self.request.query_params.get('search', None) if search: queryset = queryset.filter(title__icontains=search) content_type = self.request.query_params.get('content_type', None) if content_type: queryset = queryset.filter(content_type__contains=[{"title": content_type}]) return queryset class AdminGlossaryViewSet(viewsets.ModelViewSet): """ Admin CRUD ViewSet for base Glossary (Repository). Professors can only see glossaries linked to their own courses. """ permission_classes = [IsAuthenticated, IsPanelUser] authentication_classes = [TokenAuthentication] serializer_class = AdminGlossarySerializer pagination_class = StandardResultsSetPagination def get_queryset(self): queryset = Glossary.objects.all().order_by('-id') search = self.request.query_params.get('search', None) if search: queryset = queryset.filter(Q(title__icontains=search) | Q(description__icontains=search)) return queryset class AdminAttachmentViewSet(viewsets.ModelViewSet): """ Admin CRUD ViewSet for base Attachment (Repository). Professors can only see attachments linked to their own courses. """ permission_classes = [IsAuthenticated, IsPanelUser] authentication_classes = [TokenAuthentication] serializer_class = AdminAttachmentSerializer pagination_class = StandardResultsSetPagination def get_queryset(self): queryset = Attachment.objects.all().order_by('-id') search = self.request.query_params.get('search', None) if search: queryset = queryset.filter(title__icontains=search) file_type = self.request.query_params.get('type', None) if file_type: image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'] video_extensions = ['.mp4', '.webm', '.ogg', '.mkv', '.mov', '.avi'] audio_extensions = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac'] if file_type == 'image': q_objs = Q() for ext in image_extensions: q_objs |= Q(file__iendswith=ext) queryset = queryset.filter(q_objs) elif file_type == 'video': q_objs = Q() for ext in video_extensions: q_objs |= Q(file__iendswith=ext) queryset = queryset.filter(q_objs) elif file_type == 'audio': q_objs = Q() for ext in audio_extensions: q_objs |= Q(file__iendswith=ext) queryset = queryset.filter(q_objs) elif file_type == 'other': q_objs = Q() for ext in image_extensions + video_extensions + audio_extensions: q_objs |= Q(file__iendswith=ext) queryset = queryset.exclude(q_objs) return queryset class AdminLiveSessionViewSet(viewsets.ModelViewSet): """ Admin CRUD ViewSet for Course Live Sessions. Professors can only access live sessions of their own courses. """ permission_classes = [IsAuthenticated, IsPanelUser] authentication_classes = [TokenAuthentication] pagination_class = StandardResultsSetPagination def get_serializer_class(self): if self.action == 'list': return AdminLiveSessionListSerializer return AdminLiveSessionDetailSerializer def get_queryset(self): queryset = CourseLiveSession.objects.all().select_related('course') # Professors can only see live sessions of their own courses if is_professor(self.request): queryset = queryset.filter(course__professor_id=self.request.user.id) search = self.request.query_params.get('search', None) if search: queryset = queryset.filter( Q(subject__icontains=search) | Q(room_id__icontains=search) | Q(course__title__icontains=search) ) course_id = self.request.query_params.get('course', None) if course_id: queryset = queryset.filter(course_id=course_id) started_after = self.request.query_params.get('started_after', None) if started_after: queryset = queryset.filter(started_at__gte=started_after) started_before = self.request.query_params.get('started_before', None) if started_before: queryset = queryset.filter(started_at__lte=started_before) ended_after = self.request.query_params.get('ended_after', None) if ended_after: queryset = queryset.filter(ended_at__gte=ended_after) ended_before = self.request.query_params.get('ended_before', None) if ended_before: queryset = queryset.filter(ended_at__lte=ended_before) return queryset.order_by('-started_at') def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) if page is not None: self._sync_page_live_sessions(page) serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) sessions = list(queryset) self._sync_page_live_sessions(sessions) serializer = self.get_serializer(sessions, many=True) return Response(serializer.data) def _sync_page_live_sessions(self, sessions): for session in sessions: if session.ended_at or not session.room_id: continue if self._is_room_still_active(session.room_id): continue stop_session_web_egress_if_active(session) now = timezone.now() session.ended_at = now session.save(update_fields=['ended_at', 'updated_at']) LiveSessionUser.objects.filter( session=session, is_online=True, ).update(is_online=False, exited_at=now, updated_at=now) logger.info( f"[Admin Live Sessions] Auto-closed stale session - session_id={session.id} room_id={session.room_id}" ) @staticmethod def _is_room_still_active(room_id: str) -> bool: try: client = PlugNMeetClient() response = client.is_room_active(room_id) is_active_raw = response.get('isActive', False) is_active = ( is_active_raw if isinstance(is_active_raw, bool) else str(is_active_raw).lower() == 'true' ) response_msg = response.get('msg', '') response_status = response.get('status', False) if ( response_status and 'active' in response_msg.lower() and 'not' not in response_msg.lower() ): return True return is_active except PlugNMeetError as e: error_msg = str(e).lower() if 'not found' in error_msg or 'does not exist' in error_msg: return False logger.warning( f"[Admin Live Sessions] PlugNMeet API error while checking room {room_id}: {e}" ) return False except Exception as e: logger.warning( f"[Admin Live Sessions] Unexpected error while checking room {room_id}: {type(e).__name__}: {e}" ) return False