From cd90dfdc0c6cd2b86ced9a8a0d1fb5d83916ba47 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Tue, 2 Jun 2026 15:05:29 +0330 Subject: [PATCH] is active for lessons and chapters updated --- apps/course/access.py | 18 +++ apps/course/serializers/lesson.py | 39 +++-- .../course/tests/test_course_access_guards.py | 133 ++++++++++++++++++ apps/course/views/lesson.py | 7 + apps/quiz/serializers/participant.py | 5 + apps/quiz/serializers/quiz.py | 21 +-- apps/quiz/views/quiz.py | 17 ++- 7 files changed, 205 insertions(+), 35 deletions(-) create mode 100644 apps/course/access.py create mode 100644 apps/course/tests/test_course_access_guards.py diff --git a/apps/course/access.py b/apps/course/access.py new file mode 100644 index 0000000..45b69de --- /dev/null +++ b/apps/course/access.py @@ -0,0 +1,18 @@ +from apps.course.models import Participant + + +def user_has_course_access(user, course): + if not user or not getattr(user, 'is_authenticated', False): + return False + + if user.is_staff or user.is_superuser: + return True + + if course.professor_id == user.id: + return True + + return Participant.objects.filter( + student_id=user.id, + course=course, + is_active=True, + ).exists() diff --git a/apps/course/serializers/lesson.py b/apps/course/serializers/lesson.py index 58cbc5e..fbb988d 100644 --- a/apps/course/serializers/lesson.py +++ b/apps/course/serializers/lesson.py @@ -1,5 +1,6 @@ from rest_framework import serializers from apps.course.models import Lesson, CourseLesson, Participant, LessonCompletion +from apps.course.access import user_has_course_access from apps.quiz.serializers import QuizListSerializer @@ -10,6 +11,7 @@ class LessonSerializer(serializers.ModelSerializer): class CourseLessonSerializer(serializers.ModelSerializer): + is_active = serializers.SerializerMethodField() is_complated = serializers.SerializerMethodField() quizs = serializers.SerializerMethodField() permission = serializers.SerializerMethodField() @@ -21,27 +23,20 @@ class CourseLessonSerializer(serializers.ModelSerializer): class Meta: model = CourseLesson fields = ['id', 'title', 'priority', 'is_active', 'permission', 'duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quizs'] + + def get_is_active(self, obj): + return obj.is_active and self._has_access_for_object(obj) def get_permission(self, obj): + return self._has_access_for_object(obj) + + def _has_access_for_object(self, obj): if user := self._get_authenticated_user(): return self._has_access(user, obj.course) return False def _has_access(self, user, course): - """ - Check if the user has access to the course content. - """ - if user.is_staff or user.is_superuser: - return True - - if course.professor_id == user.id: - return True - - return Participant.objects.filter( - student_id=user.id, - course=course, - is_active=True - ).exists() + return user_has_course_access(user, course) def _is_participant(self, student, course): """Deprecated: use _has_access instead.""" @@ -58,6 +53,9 @@ class CourseLessonSerializer(serializers.ModelSerializer): return False user = request.user + if not self._has_access(user, obj.course): + return False + # Use prefetched completions if available if hasattr(obj, 'completions') and obj.completions.all(): return any(completion.student_id == user.id for completion in obj.completions.all()) @@ -93,14 +91,25 @@ class CourseChapterSerializer(serializers.ModelSerializer): """ V2 API: Returns a Chapter and a nested list of all its active lessons """ + is_active = serializers.SerializerMethodField() lessons = serializers.SerializerMethodField() class Meta: model = CourseChapter fields = ['id', 'title', 'priority', 'is_active', 'lessons'] + def get_is_active(self, obj): + if not obj.is_active: + return False + + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return False + + return user_has_course_access(request.user, obj.course) + def get_lessons(self, obj): # Grab all active lessons tied to this specific chapter chapter_lessons = obj.lessons.filter(is_active=True).order_by('priority') # Re-use the V1 CourseLessonSerializer to serialize the nested items - return CourseLessonSerializer(chapter_lessons, many=True, context=self.context).data \ No newline at end of file + return CourseLessonSerializer(chapter_lessons, many=True, context=self.context).data diff --git a/apps/course/tests/test_course_access_guards.py b/apps/course/tests/test_course_access_guards.py new file mode 100644 index 0000000..434737e --- /dev/null +++ b/apps/course/tests/test_course_access_guards.py @@ -0,0 +1,133 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from rest_framework import status +from rest_framework.test import APITestCase + +from apps.account.models import ProfessorUser, StudentUser +from apps.course.models.course import Course, CourseCategory +from apps.course.models.lesson import CourseChapter, CourseLesson, Lesson +from apps.quiz.models.quiz import Quiz, Question + + +class CourseAccessGuardsTests(APITestCase): + def setUp(self): + self.professor = ProfessorUser.objects.create( + email='prof-guard@example.com', + fullname='Professor Guard', + experience_years=5, + ) + self.outsider = StudentUser.objects.create( + email='outsider@example.com', + fullname='Outsider Student', + ) + self.category = CourseCategory.objects.create( + name='Guard Category', + slug='guard-category', + ) + thumbnail = SimpleUploadedFile('guard.jpg', b'filecontent', content_type='image/jpeg') + self.course = Course.objects.create( + title='Guard Course', + slug='guard-course', + category=self.category, + professor=self.professor, + thumbnail=thumbnail, + video_type=Course.VedioTypeChoices.YOUTUBE_LINK, + video_link='https://example.com/video', + is_online=False, + level=Course.LevelChoices.BEGINNER, + duration=10, + lessons_count=0, + description='Description', + short_description='Short description', + status=Course.StatusChoices.ONGOING, + is_free=True, + ) + self.chapter = CourseChapter.objects.create( + course=self.course, + title='Chapter 1', + priority=1, + is_active=True, + ) + lesson = Lesson.objects.create( + title='Lesson 1', + content_type=Lesson.ContentTypeChoices.VIDEO_FILE, + duration=5, + ) + self.course_lesson = CourseLesson.objects.create( + course=self.course, + chapter=self.chapter, + lesson=lesson, + priority=1, + is_active=True, + ) + self.quiz = Quiz.objects.create( + lesson=self.course_lesson, + course=self.course, + title='Guard Quiz', + description='Quiz description', + each_question_timing=30, + status=True, + ) + self.question = Question.objects.create( + quiz=self.quiz, + question='What is 2+2?', + option1='4', + option2='3', + option3='2', + option4='1', + correct_answer=1, + priority=1, + ) + + def test_lesson_list_marks_lessons_and_quizzes_inactive_without_course_access(self): + self.client.force_authenticate(user=self.outsider) + + response = self.client.get(f'/api/courses/{self.course.slug}/lessons/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + lesson_data = response.data['results'][0] + self.assertFalse(lesson_data['permission']) + self.assertFalse(lesson_data['is_active']) + self.assertFalse(lesson_data['quizs'][0]['permission']) + + def test_lesson_list_v2_marks_chapter_inactive_without_course_access(self): + self.client.force_authenticate(user=self.outsider) + + response = self.client.get(f'/api/courses/v2/{self.course.slug}/lessons/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + chapter_data = response.data['results'][0] + self.assertFalse(chapter_data['is_active']) + self.assertFalse(chapter_data['lessons'][0]['is_active']) + + def test_quiz_detail_rejects_users_without_course_access(self): + self.client.force_authenticate(user=self.outsider) + + response = self.client.get(f'/api/quiz/{self.quiz.id}/') + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_quiz_submit_rejects_users_without_course_access(self): + self.client.force_authenticate(user=self.outsider) + + payload = { + 'quiz': self.quiz.id, + 'started_at': '2026-06-02T10:00:00Z', + 'ended_at': '2026-06-02T10:01:00Z', + 'total_timing': 60, + 'question_score': 1, + 'timing_score': 1, + 'total_score': 2, + 'answers': [ + { + 'question': self.question.id, + 'option_num': 1, + 'at_time': '2026-06-02T10:00:30Z', + 'answer_timing': 30, + } + ], + } + + response = self.client.post('/api/quiz/submit-quiz/', payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('quiz', response.data) diff --git a/apps/course/views/lesson.py b/apps/course/views/lesson.py index 13d29fa..51e5dae 100644 --- a/apps/course/views/lesson.py +++ b/apps/course/views/lesson.py @@ -13,6 +13,7 @@ from apps.course.serializers import ( CourseChapterSerializer ) from apps.course.models import Course, CourseLesson, LessonCompletion +from apps.course.access import user_has_course_access from apps.course.doc import * from utils.exceptions import AppAPIException from utils.pagination import StandardResultsSetPagination @@ -165,6 +166,9 @@ class LessonCompletionToggleAPIView(GenericAPIView): course_lessons = CourseLesson.objects.filter(id__in=lesson_ids) found_ids = set(course_lessons.values_list('id', flat=True)) + if any(not user_has_course_access(student, course_lesson.course) for course_lesson in course_lessons): + return Response({'error': 'You do not have access to one or more lessons.'}, status=status.HTTP_403_FORBIDDEN) + existing_completions = LessonCompletion.objects.filter( student=student, course_lesson_id__in=found_ids @@ -201,6 +205,9 @@ class LessonCompletionToggleAPIView(GenericAPIView): except CourseLesson.DoesNotExist: return Response({'error': 'Lesson not found.'}, status=status.HTTP_404_NOT_FOUND) + if not user_has_course_access(student, course_lesson.course): + return Response({'error': 'You do not have access to this lesson.'}, status=status.HTTP_403_FORBIDDEN) + # TOGGLE LOGIC completion = LessonCompletion.objects.filter(student=student, course_lesson=course_lesson).first() diff --git a/apps/quiz/serializers/participant.py b/apps/quiz/serializers/participant.py index 2ec1280..ba931f7 100644 --- a/apps/quiz/serializers/participant.py +++ b/apps/quiz/serializers/participant.py @@ -1,5 +1,6 @@ from rest_framework import serializers +from apps.course.access import user_has_course_access from apps.quiz.models import QuizParticipant, ParticipantAnswer @@ -18,6 +19,10 @@ class QuizParticipantSerializer(serializers.ModelSerializer): if QuizParticipant.objects.filter(quiz=obj, user=self.context['request'].user).exists(): raise serializers.ValidationError('you have already participated in the quiz') + course = getattr(obj, 'course', None) + if course and not user_has_course_access(self.context['request'].user, course): + raise serializers.ValidationError('you do not have access to this quiz') + return obj class Meta: diff --git a/apps/quiz/serializers/quiz.py b/apps/quiz/serializers/quiz.py index 6156e64..e0074b1 100644 --- a/apps/quiz/serializers/quiz.py +++ b/apps/quiz/serializers/quiz.py @@ -1,7 +1,7 @@ from rest_framework import serializers +from apps.course.access import user_has_course_access from apps.quiz.models import Question, Quiz, QuizParticipant -from apps.course.models import Participant @@ -21,27 +21,20 @@ class QuizListSerializer(serializers.ModelSerializer): request = self.context.get('request') if not request or not request.user.is_authenticated: return False - # Check if the user has participated in this quiz user = request.user - # obj.lesson is now CourseLesson directly course_lesson = obj.lesson if not course_lesson: return False course = course_lesson.course - if not self._is_participant(user, course): + if not user_has_course_access(user, course): return False participated = QuizParticipant.objects.filter(user=user, quiz=obj).exists() return not participated - - def _is_participant(self, student, course): - """Helper method to check if a student is a participant in the given course.""" - return Participant.objects.filter(student=student, course=course).exists() - def get_is_complated(self, obj): request = self.context.get('request') if not request or not request.user.is_authenticated: @@ -82,19 +75,11 @@ class QuizSerializer(serializers.ModelSerializer): request = self.context.get('request') if not request or not request.user.is_authenticated: return False - # Check if the user has participated in this quiz user = request.user - # obj.lesson is now CourseLesson directly course_lesson = obj.lesson if not course_lesson: return False course = course_lesson.course - - # Check if user is a participant in the course - if not Participant.objects.filter(student=user, course=course).exists(): - return False - - participated = QuizParticipant.objects.filter(user=user, quiz=obj).exists() - return participated + return user_has_course_access(user, course) diff --git a/apps/quiz/views/quiz.py b/apps/quiz/views/quiz.py index 49a6fa3..6d5284e 100644 --- a/apps/quiz/views/quiz.py +++ b/apps/quiz/views/quiz.py @@ -1,13 +1,16 @@ from django.db.models import Value +from django.shortcuts import get_object_or_404 from rest_framework.generics import RetrieveAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.authentication import TokenAuthentication from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi +from apps.course.access import user_has_course_access from apps.quiz.models import Quiz from apps.quiz.serializers.quiz import QuizSerializer from apps.quiz.doc import * +from utils.exceptions import AppAPIException @@ -25,8 +28,18 @@ class QuizDetailAPIView(RetrieveAPIView): return super().get(request, *args, **kwargs) def get_object(self): - return Quiz.objects.filter( + quiz = get_object_or_404( + Quiz.objects.filter( id=self.kwargs['quiz_id'], ).annotate( lesson__has_quiz=Value(True) - ).select_related('lesson').first() + ).select_related('lesson', 'course') + ) + + if quiz.course and not user_has_course_access(self.request.user, quiz.course): + raise AppAPIException( + {'message': 'You do not have access to this quiz.'}, + status_code=403, + ) + + return quiz