You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

463 lines
18 KiB

import logging
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.generics import GenericAPIView
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiParameter
from apps.lms.models import (
Course,
CourseModule,
Lesson,
QuizQuestion,
CourseEnrollment,
LessonProgress,
DigitalCertificate,
)
from apps.lms.permissions import IsCourseInstructorOrAdmin
from apps.lms.serializers import (
CourseListSerializer,
CourseDetailSerializer,
CourseCreateUpdateSerializer,
CourseModuleSerializer,
LessonDetailSerializer,
QuizSubmitRequestSerializer,
QuizResultSerializer,
CourseProgressSerializer,
LessonCompleteResponseSerializer,
DigitalCertificateSerializer,
PublicCertificateVerifySerializer,
)
logger = logging.getLogger(__name__)
def get_course_by_pk_or_slug(pk_or_slug, queryset=None):
if queryset is None:
queryset = Course.objects.all()
if pk_or_slug.isdigit():
return get_object_or_404(queryset, pk=int(pk_or_slug))
return get_object_or_404(queryset, slug=pk_or_slug)
class CourseListCreateView(GenericAPIView):
permission_classes = [AllowAny]
serializer_class = CourseListSerializer
queryset = Course.objects.filter(is_published=True)
@extend_schema(
summary="List online academy courses",
description="Retrieves a list of published courses with category, language, level, and keyword filtering.",
parameters=[
OpenApiParameter('category', str, description="Filter by course category (e.g. razavi_teachings, cultural_diplomacy)"),
OpenApiParameter('language', str, description="Filter by language code (fa, ar, en, ur, fr)"),
OpenApiParameter('level', str, description="Filter by difficulty level (introductory, intermediate, advanced)"),
OpenApiParameter('search', str, description="Keyword search in title, instructor, or description"),
OpenApiParameter('featured', bool, description="Filter only featured courses"),
],
responses={200: CourseListSerializer(many=True)},
tags=["LMS - Online Academy"],
)
def get(self, request, *args, **kwargs):
qs = Course.objects.all()
# If user is not staff, only show published courses
if not (request.user and request.user.is_authenticated and request.user.is_staff):
qs = qs.filter(is_published=True)
category = request.query_params.get('category')
if category:
qs = qs.filter(category=category)
language = request.query_params.get('language')
if language:
qs = qs.filter(language=language)
level = request.query_params.get('level')
if level:
qs = qs.filter(level=level)
featured = request.query_params.get('featured')
if featured is not None:
qs = qs.filter(is_featured=featured.lower() in ('true', '1'))
search = request.query_params.get('search')
if search:
qs = qs.filter(
Q(title__icontains=search) |
Q(description__icontains=search) |
Q(instructor_name__icontains=search) |
Q(organization_name__icontains=search)
)
serializer = self.get_serializer(qs, many=True, context={'request': request})
return Response(serializer.data, status=status.HTTP_200_OK)
@extend_schema(
summary="Create a new course",
description="Creates a new LMS course. Creator is automatically set as course author.",
request=CourseCreateUpdateSerializer,
responses={
201: CourseDetailSerializer,
400: OpenApiResponse(description="Validation error"),
403: OpenApiResponse(description="Permission denied"),
},
tags=["LMS - Online Academy"],
)
def post(self, request, *args, **kwargs):
if not request.user or not request.user.is_authenticated:
return Response({'detail': _("Authentication credentials were not provided.")}, status=status.HTTP_401_UNAUTHORIZED)
serializer = CourseCreateUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
course = serializer.save(created_by=request.user)
response_serializer = CourseDetailSerializer(course, context={'request': request})
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
class CourseDetailView(GenericAPIView):
permission_classes = [AllowAny]
serializer_class = CourseDetailSerializer
queryset = Course.objects.all()
@extend_schema(
summary="Retrieve course syllabus and details",
description="Returns full course information, curriculum modules, lessons list, instructor details, and authenticated user enrollment status.",
responses={
200: CourseDetailSerializer,
404: OpenApiResponse(description="Course not found"),
},
tags=["LMS - Online Academy"],
)
def get(self, request, pk_or_slug, *args, **kwargs):
course = get_course_by_pk_or_slug(pk_or_slug, self.get_queryset())
serializer = self.get_serializer(course, context={'request': request})
return Response(serializer.data, status=status.HTTP_200_OK)
@extend_schema(
summary="Update course metadata",
description="Partially updates course information. Restricted to instructor or platform administrator.",
request=CourseCreateUpdateSerializer,
responses={
200: CourseDetailSerializer,
403: OpenApiResponse(description="Permission denied"),
},
tags=["LMS - Online Academy"],
)
def patch(self, request, pk_or_slug, *args, **kwargs):
course = get_course_by_pk_or_slug(pk_or_slug, self.get_queryset())
self.check_object_permissions(request, course)
serializer = CourseCreateUpdateSerializer(course, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
response_serializer = CourseDetailSerializer(course, context={'request': request})
return Response(response_serializer.data, status=status.HTTP_200_OK)
@extend_schema(
summary="Delete a course",
description="Deletes a course and its modules. Restricted to instructor or platform administrator.",
responses={204: OpenApiResponse(description="Course deleted")},
tags=["LMS - Online Academy"],
)
def delete(self, request, pk_or_slug, *args, **kwargs):
course = get_course_by_pk_or_slug(pk_or_slug, self.get_queryset())
self.check_object_permissions(request, course)
course.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class CourseEnrollView(GenericAPIView):
permission_classes = [IsAuthenticated]
serializer_class = CourseDetailSerializer
queryset = Course.objects.all()
@extend_schema(
summary="Enroll current user in course",
description="Enrolls the authenticated user into the specified course. Returns enrollment status.",
responses={
200: OpenApiResponse(description="Already enrolled"),
201: OpenApiResponse(description="Successfully enrolled"),
404: OpenApiResponse(description="Course not found"),
},
tags=["LMS - Online Academy"],
)
def post(self, request, pk_or_slug, *args, **kwargs):
course = get_course_by_pk_or_slug(pk_or_slug, self.get_queryset())
enrollment, created = CourseEnrollment.objects.get_or_create(
user=request.user,
course=course
)
status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK
return Response({
'message': _("Enrolled successfully.") if created else _("Already enrolled in this course."),
'course_id': course.id,
'course_title': course.title,
'enrolled_at': enrollment.enrolled_at,
'progress_percentage': enrollment.progress_percentage,
}, status=status_code)
class CourseProgressView(GenericAPIView):
permission_classes = [IsAuthenticated]
serializer_class = CourseProgressSerializer
queryset = Course.objects.all()
@extend_schema(
summary="Get user progress in course",
description="Returns completion percentage, completed lessons tally, and certificate code if finished.",
responses={
200: CourseProgressSerializer,
404: OpenApiResponse(description="Course or enrollment not found"),
},
tags=["LMS - Online Academy"],
)
def get(self, request, pk_or_slug, *args, **kwargs):
course = get_course_by_pk_or_slug(pk_or_slug, self.get_queryset())
enrollment = CourseEnrollment.objects.filter(user=request.user, course=course).first()
all_lesson_ids = Lesson.objects.filter(module__course=course).values_list('id', flat=True)
total_lessons = len(all_lesson_ids)
completed_lessons = LessonProgress.objects.filter(
user=request.user,
lesson_id__in=all_lesson_ids,
is_completed=True
).count()
cert = DigitalCertificate.objects.filter(user=request.user, course=course).first()
data = {
'course_id': course.id,
'course_title': course.title,
'progress_percentage': enrollment.progress_percentage if enrollment else 0,
'completed_lessons_count': completed_lessons,
'total_lessons_count': total_lessons,
'is_completed': (enrollment.progress_percentage >= 100) if enrollment else False,
'certificate_code': cert.certificate_code if cert else None,
}
return Response(data, status=status.HTTP_200_OK)
class LessonDetailView(GenericAPIView):
permission_classes = [AllowAny]
serializer_class = LessonDetailSerializer
queryset = Lesson.objects.all()
@extend_schema(
summary="Get lesson details and content",
description="Retrieves lesson video link, markdown content, slides attachment, and quiz questions.",
responses={
200: LessonDetailSerializer,
404: OpenApiResponse(description="Lesson not found"),
},
tags=["LMS - Online Academy"],
)
def get(self, request, pk, *args, **kwargs):
lesson = get_object_or_404(Lesson, pk=pk)
serializer = self.get_serializer(lesson, context={'request': request})
return Response(serializer.data, status=status.HTTP_200_OK)
class LessonCompleteView(GenericAPIView):
permission_classes = [IsAuthenticated]
serializer_class = LessonCompleteResponseSerializer
queryset = Lesson.objects.all()
@extend_schema(
summary="Mark lesson as complete",
description="Marks a reading or video lesson as complete. Automatically recalculates course progress and issues certificate if 100%.",
responses={
200: LessonCompleteResponseSerializer,
404: OpenApiResponse(description="Lesson not found"),
},
tags=["LMS - Online Academy"],
)
def post(self, request, pk, *args, **kwargs):
lesson = get_object_or_404(Lesson, pk=pk)
course = lesson.module.course
# Ensure enrollment exists
enrollment, _created = CourseEnrollment.objects.get_or_create(
user=request.user,
course=course
)
progress, _p_created = LessonProgress.objects.get_or_create(
user=request.user,
lesson=lesson
)
progress.is_completed = True
progress.completed_at = timezone.now()
progress.save()
# Recalculate course overall progress
new_progress = enrollment.recalculate_progress()
cert = DigitalCertificate.objects.filter(user=request.user, course=course).first()
return Response({
'message': _("Lesson marked as complete."),
'lesson_id': lesson.id,
'is_completed': True,
'course_progress_percentage': new_progress,
'certificate_code': cert.certificate_code if cert else None,
}, status=status.HTTP_200_OK)
class LessonQuizSubmitView(GenericAPIView):
permission_classes = [IsAuthenticated]
serializer_class = QuizSubmitRequestSerializer
queryset = Lesson.objects.all()
@extend_schema(
summary="Submit quiz answers and evaluate score",
description="Evaluates user quiz responses. If score >= 70%, marks lesson complete, recalculates progress, and issues certificate if 100%.",
request=QuizSubmitRequestSerializer,
responses={
200: QuizResultSerializer,
400: OpenApiResponse(description="Invalid submission"),
404: OpenApiResponse(description="Lesson not found"),
},
tags=["LMS - Online Academy"],
)
def post(self, request, pk, *args, **kwargs):
lesson = get_object_or_404(Lesson, pk=pk)
course = lesson.module.course
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
answers = serializer.validated_data['answers']
# Ensure user is enrolled
enrollment, _e_created = CourseEnrollment.objects.get_or_create(
user=request.user,
course=course
)
questions = lesson.quiz_questions.all().order_by('order', 'id')
total_questions = questions.count()
if total_questions == 0:
return Response({'error': _("No questions found in this quiz.")}, status=status.HTTP_400_BAD_REQUEST)
# Map answers
answers_map = {ans['question_id']: ans['selected_option'] for ans in answers}
correct_count = 0
detailed_results = []
for q in questions:
user_choice = answers_map.get(q.id)
is_correct = (user_choice == q.correct_option_index)
if is_correct:
correct_count += 1
detailed_results.append({
'question_id': q.id,
'is_correct': is_correct,
'selected_option': user_choice if user_choice is not None else -1,
'correct_option': q.correct_option_index,
'explanation': q.explanation or '',
})
score = round((correct_count / total_questions) * 100.0, 1)
passed = score >= 70.0
progress, _prog_created = LessonProgress.objects.get_or_create(
user=request.user,
lesson=lesson
)
progress.quiz_score = score
if passed:
progress.is_completed = True
progress.completed_at = timezone.now()
progress.save()
# Recalculate progress if passed
new_progress = enrollment.recalculate_progress()
cert = DigitalCertificate.objects.filter(user=request.user, course=course).first()
result_data = {
'score': score,
'passed': passed,
'total_questions': total_questions,
'correct_count': correct_count,
'question_results': detailed_results,
'is_lesson_completed': progress.is_completed,
'course_progress_percentage': new_progress,
'certificate_code': cert.certificate_code if cert else None,
}
return Response(result_data, status=status.HTTP_200_OK)
class DigitalCertificateListView(GenericAPIView):
permission_classes = [IsAuthenticated]
serializer_class = DigitalCertificateSerializer
queryset = DigitalCertificate.objects.all()
def get_queryset(self):
if getattr(self, 'swagger_fake_view', False) or not self.request.user.is_authenticated:
return DigitalCertificate.objects.none()
return DigitalCertificate.objects.filter(user=self.request.user, is_revoked=False)
@extend_schema(
summary="List user earned digital certificates",
description="Retrieves a list of all official digital certificates earned by the authenticated user across completed academy courses.",
responses={200: DigitalCertificateSerializer(many=True)},
tags=["LMS - Online Academy"],
)
def get(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class PublicCertificateVerifyView(GenericAPIView):
permission_classes = [AllowAny]
serializer_class = PublicCertificateVerifySerializer
queryset = DigitalCertificate.objects.all()
@extend_schema(
summary="Verify digital certificate authenticity (Public / QR)",
description="Public endpoint for verifying credentials by certificate code or UUID. Does not require login.",
responses={
200: PublicCertificateVerifySerializer,
404: OpenApiResponse(description="Certificate code not found"),
},
tags=["LMS - Online Academy"],
)
def get(self, request, code, *args, **kwargs):
# Look up by code or uuid
cert = None
try:
cert = DigitalCertificate.objects.filter(
Q(certificate_code__iexact=code.strip()) |
Q(uuid__iexact=code.strip())
).first()
except Exception:
pass
if not cert:
return Response({
'is_valid': False,
'detail': _("Certificate with this verification code was not found or has been revoked.")
}, status=status.HTTP_404_NOT_FOUND)
data = {
'certificate_code': cert.certificate_code,
'uuid': cert.uuid,
'recipient_name': cert.user.get_full_name(),
'course_title': cert.course.title,
'organization': cert.course.organization_name or (_("Razavi International Hub") if not cert.course.institution else cert.course.institution.name),
'issue_date': cert.issue_date,
'final_score': cert.final_score,
'is_valid': not cert.is_revoked,
}
return Response(data, status=status.HTTP_200_OK)