From b45860cfbd6fe2e87037ee853a473b2a91045f0c Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Tue, 2 Jun 2026 13:50:31 +0330 Subject: [PATCH] course filters updated --- apps/course/tests/test_my_courses_api.py | 145 +++++++++++++++++++++++ apps/course/views/course.py | 49 +++++--- 2 files changed, 177 insertions(+), 17 deletions(-) create mode 100644 apps/course/tests/test_my_courses_api.py diff --git a/apps/course/tests/test_my_courses_api.py b/apps/course/tests/test_my_courses_api.py new file mode 100644 index 0000000..11a95e1 --- /dev/null +++ b/apps/course/tests/test_my_courses_api.py @@ -0,0 +1,145 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +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, LessonCompletion +from apps.course.models.participant import Participant + + +class MyCoursesAPITests(APITestCase): + def setUp(self): + self.professor = ProfessorUser.objects.create( + email='prof@example.com', + fullname='Professor Example', + experience_years=5, + ) + self.student = StudentUser.objects.create( + email='student@example.com', + fullname='Student Example', + ) + self.category = CourseCategory.objects.create( + name='Category', + slug='category', + ) + + def _create_course(self, slug, title): + thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg') + return Course.objects.create( + title=title, + slug=slug, + 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, + ) + + def _attach_lessons(self, course, lesson_specs): + chapter = CourseChapter.objects.create( + course=course, + title=f'{course.title} Chapter', + priority=1, + is_active=True, + ) + + created_lessons = [] + for index, spec in enumerate(lesson_specs, start=1): + lesson = Lesson.objects.create( + title=spec['title'], + content_type=Lesson.ContentTypeChoices.VIDEO_FILE, + duration=5, + ) + course_lesson = CourseLesson.objects.create( + course=course, + chapter=chapter, + lesson=lesson, + priority=index, + is_active=spec.get('is_active', True), + ) + created_lessons.append(course_lesson) + + return created_lessons + + def test_completed_filter_returns_only_courses_with_all_active_lessons_completed(self): + completed_course = self._create_course('completed-course', 'Completed Course') + partial_course = self._create_course('partial-course', 'Partial Course') + empty_course = self._create_course('empty-course', 'Empty Course') + + completed_lessons = self._attach_lessons( + completed_course, + [ + {'title': 'Completed Lesson 1'}, + {'title': 'Completed Lesson 2'}, + {'title': 'Inactive Lesson', 'is_active': False}, + ], + ) + partial_lessons = self._attach_lessons( + partial_course, + [ + {'title': 'Partial Lesson 1'}, + {'title': 'Partial Lesson 2'}, + ], + ) + + Participant.objects.create(student=self.student, course=completed_course) + Participant.objects.create(student=self.student, course=partial_course) + Participant.objects.create(student=self.student, course=empty_course) + + LessonCompletion.objects.create(student=self.student, course_lesson=completed_lessons[0]) + LessonCompletion.objects.create(student=self.student, course_lesson=completed_lessons[1]) + LessonCompletion.objects.create(student=self.student, course_lesson=partial_lessons[0]) + + self.client.force_authenticate(user=self.student) + url = reverse('course-my-courses-list') + response = self.client.get(url, {'completed': 'true'}, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + self.assertEqual({item['slug'] for item in response.data['results']}, {completed_course.slug}) + + def test_my_courses_without_completed_filter_returns_completed_and_incomplete_courses(self): + completed_course = self._create_course('all-completed-course', 'All Completed Course') + partial_course = self._create_course('all-partial-course', 'All Partial Course') + + completed_lessons = self._attach_lessons( + completed_course, + [ + {'title': 'Completed Lesson 1'}, + ], + ) + partial_lessons = self._attach_lessons( + partial_course, + [ + {'title': 'Partial Lesson 1'}, + {'title': 'Partial Lesson 2'}, + ], + ) + + Participant.objects.create(student=self.student, course=completed_course) + Participant.objects.create(student=self.student, course=partial_course) + + LessonCompletion.objects.create(student=self.student, course_lesson=completed_lessons[0]) + LessonCompletion.objects.create(student=self.student, course_lesson=partial_lessons[0]) + + self.client.force_authenticate(user=self.student) + url = reverse('course-my-courses-list') + response = self.client.get(url, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 2) + self.assertEqual( + {item['slug'] for item in response.data['results']}, + {completed_course.slug, partial_course.slug}, + ) diff --git a/apps/course/views/course.py b/apps/course/views/course.py index 36bb66e..d95309d 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -240,27 +240,42 @@ class MyCourseListAPIView(ListAPIView): # Include courses where user is a student OR the professor qs = queryset.filter(Q(participants__student=user) | Q(professor=user)).distinct() - completed_only = filters.get('completed', '').lower() == 'true' - if completed_only == True: - # نمایش دوره‌هایی که همه درس‌هایشان توسط کاربر تکمیل شده‌اند - qs = qs.annotate( - # Count distinct lessons across all chapters - total_lessons=Count('chapters__lessons', distinct=True), - completed_lessons=Count( - 'chapters__lessons__completions', - filter=Q(chapters__lessons__completions__student=user), - distinct=True - ) - ).filter(total_lessons=F('completed_lessons')) - elif completed_only == False: + completed_param = filters.get('completed') + if completed_param is not None: + completed_only = completed_param.lower() == 'true' + # Only active chapters/lessons should participate in completion checks. + total_lessons_filter = Q( + chapters__is_active=True, + chapters__lessons__is_active=True, + ) + completed_lessons_filter = Q( + chapters__is_active=True, + chapters__lessons__is_active=True, + chapters__lessons__completions__student=user, + ) + qs = qs.annotate( - total_lessons=Count('chapters__lessons', distinct=True), + total_lessons=Count( + 'chapters__lessons', + filter=total_lessons_filter, + distinct=True, + ), completed_lessons=Count( 'chapters__lessons__completions', - filter=Q(chapters__lessons__completions__student=user), - distinct=True + filter=completed_lessons_filter, + distinct=True, + ), + ) + + if completed_only: + # A course is "completed" only when every active lesson is completed. + # Courses with no active lessons should not be marked as completed. + qs = qs.filter( + total_lessons__gt=0, + total_lessons=F('completed_lessons'), ) - ).filter(total_lessons__gt=F('completed_lessons')) + else: + qs = qs.filter(total_lessons__gt=F('completed_lessons')) if 'completed' not in filters: certificate = filters.get('certificate', '').lower() == 'true'