import logging import re from copy import deepcopy from django.db import transaction from django.db.models import Count, 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.models.course import extract_text_from_json from apps.quiz.models import Quiz, Question 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').prefetch_related('professors') # Professors can only see their own courses if is_professor(self.request): queryset = queryset.filter(professors=self.request.user) # 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) professor_ids = self.request.query_params.getlist('professor') if professor_ids: queryset = queryset.filter(professors__in=professor_ids).distinct() 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=self.request.user) 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 not instance.professors.filter(id=self.request.user.id).exists(): raise PermissionDenied("You can only edit your own courses.") serializer.save(professor=self.request.user) else: serializer.save() def _build_cloned_title(self, source_course, requested_title=None): custom_title = str(requested_title or "").strip() if custom_title: return [{"language_code": "en", "title": custom_title}] source_titles = deepcopy(source_course.title or []) source_title_text = extract_text_from_json(source_titles).strip() or f"Course {source_course.pk}" base_title = re.sub(r"\s*\(copied(?:#\d+)?\)$", "", source_title_text).strip() or source_title_text suffix = "(copied)" if isinstance(source_titles, list) and source_titles: localized_titles = [] for item in source_titles: if isinstance(item, dict): localized_item = deepcopy(item) for key in ("title", "text", "value", "name"): if key in localized_item and localized_item.get(key): cleaned_value = re.sub(r"\s*\(copied(?:#\d+)?\)$", "", str(localized_item[key])).strip() localized_item[key] = f"{cleaned_value}{suffix}" break else: localized_item["title"] = f"{base_title}{suffix}" localized_titles.append(localized_item) elif item: cleaned_item = re.sub(r"\s*\(copied(?:#\d+)?\)$", "", str(item)).strip() localized_titles.append(f"{cleaned_item}{suffix}") if localized_titles: return localized_titles return [{"language_code": "en", "title": f"{base_title}{suffix}"}] @action(detail=True, methods=['post']) def clone(self, request, pk=None): source_course = self.get_object() requested_title = request.data.get('title') with transaction.atomic(): cloned_course = Course.objects.create( title=self._build_cloned_title(source_course, requested_title), category=source_course.category, thumbnail=source_course.thumbnail.name if source_course.thumbnail else None, video_type=source_course.video_type, video_file=source_course.video_file.name if source_course.video_file else None, video_link=source_course.video_link, is_online=source_course.is_online, online_link=source_course.online_link, level=deepcopy(source_course.level), duration=source_course.duration, lessons_count=source_course.lessons_count, description=deepcopy(source_course.description), short_description=deepcopy(source_course.short_description), status=deepcopy(source_course.status), is_free=source_course.is_free, price=source_course.price, price_rub=source_course.price_rub, discount_percentage=source_course.discount_percentage, is_group_chat_locked=source_course.is_group_chat_locked, is_professor_chat_locked=source_course.is_professor_chat_locked, timing=deepcopy(source_course.timing), features=deepcopy(source_course.features), ) cloned_course.professors.set(source_course.professors.all()) chapter_map = {} source_chapters = source_course.chapters.all().order_by('priority', 'id') for chapter in source_chapters: cloned_chapter = CourseChapter.objects.create( course=cloned_course, title=deepcopy(chapter.title), priority=chapter.priority, is_active=chapter.is_active, ) chapter_map[chapter.id] = cloned_chapter lesson_map = {} source_lessons = source_course.lessons.select_related('lesson', 'chapter').all().order_by('priority', 'id') for course_lesson in source_lessons: base_lesson = course_lesson.lesson cloned_lesson = Lesson.objects.create( title=deepcopy(base_lesson.title), content_type=deepcopy(base_lesson.content_type), content_file=base_lesson.content_file.name if base_lesson and base_lesson.content_file else None, video_link=base_lesson.video_link if base_lesson else None, duration=base_lesson.duration if base_lesson else 0, ) cloned_course_lesson = CourseLesson.objects.create( course=cloned_course, chapter=chapter_map.get(course_lesson.chapter_id), lesson=cloned_lesson, title=deepcopy(course_lesson.title), priority=course_lesson.priority, is_active=course_lesson.is_active, ) lesson_map[course_lesson.id] = cloned_course_lesson source_attachments = source_course.attachments.select_related('attachment').all() for course_attachment in source_attachments: base_attachment = course_attachment.attachment cloned_attachment = Attachment.objects.create( title=deepcopy(base_attachment.title), file=base_attachment.file.name if base_attachment and base_attachment.file else None, file_size=base_attachment.file_size if base_attachment else None, ) CourseAttachment.objects.create( course=cloned_course, attachment=cloned_attachment, ) source_glossaries = source_course.glossaries.select_related('glossary').all() for course_glossary in source_glossaries: base_glossary = course_glossary.glossary cloned_glossary = Glossary.objects.create( title=base_glossary.title, description=deepcopy(base_glossary.description), ) CourseGlossary.objects.create( course=cloned_course, glossary=cloned_glossary, ) source_quizzes = source_course.quizzes.prefetch_related('questions').all() for quiz in source_quizzes: cloned_quiz = Quiz.objects.create( lesson=lesson_map.get(quiz.lesson_id), course=cloned_course, lesson_number=quiz.lesson_number, title=deepcopy(quiz.title), description=deepcopy(quiz.description), each_question_timing=quiz.each_question_timing, status=quiz.status, ) for question in quiz.questions.all(): Question.objects.create( quiz=cloned_quiz, question=deepcopy(question.question), option1=deepcopy(question.option1), option2=deepcopy(question.option2), option3=deepcopy(question.option3), option4=deepcopy(question.option4), correct_answer=question.correct_answer, priority=question.priority, ) cloned_course.recalculate_lessons_count() cloned_course.refresh_from_db() serializer = self.get_serializer(cloned_course) return Response(serializer.data, status=201) 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__professors=self.request.user) 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__professors=self.request.user) 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__professors=self.request.user) 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, professors=request.user).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__professors=self.request.user) 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, professors=request.user).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').annotate( media_count=Count('recordings', distinct=True) ) # Professors can only see live sessions of their own courses if is_professor(self.request): queryset = queryset.filter(course__professors=self.request.user) 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) professor_ids = self.request.query_params.getlist('professor') if professor_ids: queryset = queryset.filter(course__professors__in=professor_ids).distinct() time_range = self.request.query_params.get('time_range', None) if time_range: from datetime import timedelta now = timezone.now() if time_range == '3_days': queryset = queryset.filter(started_at__gte=now - timedelta(days=3)) elif time_range == 'week': queryset = queryset.filter(started_at__gte=now - timedelta(days=7)) elif time_range == 'month': queryset = queryset.filter(started_at__gte=now - timedelta(days=30)) elif time_range == 'year': queryset = queryset.filter(started_at__gte=now - timedelta(days=365)) 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