diff --git a/futurex_openedx_extensions/dashboard/details/learners.py b/futurex_openedx_extensions/dashboard/details/learners.py index 78bb666c..67b6310b 100644 --- a/futurex_openedx_extensions/dashboard/details/learners.py +++ b/futurex_openedx_extensions/dashboard/details/learners.py @@ -3,6 +3,7 @@ from datetime import timedelta +from common.djangoapps.student.models import CourseEnrollment from django.contrib.auth import get_user_model from django.db.models import BooleanField, Case, Count, Exists, OuterRef, Q, Subquery, Value, When from django.db.models.query import QuerySet @@ -250,3 +251,64 @@ def get_learner_info_queryset( ).select_related('profile') return queryset + + +def get_learners_enrollments_queryset( + user_ids: list = None, course_ids: list = None, include_staff: bool = False +) -> QuerySet: + """ + Get the enrollment details. If no course_ids or user_ids are provided, + all relevant data will be processed. + + :param course_ids: List of course IDs to filter by (optional). + :param user_ids: List of user IDs to filter by (optional). + :param search_text: Text to filter users by (optional). + :param include_staff: Flag to include staff users (default: False). + :return: List of dictionaries containing user and course details. + """ + + course_filter = Q(is_active=True) + if course_ids: + course_filter &= Q(course_id__in=course_ids) + if user_ids: + course_filter &= Q(user_id__in=user_ids) + + queryset = CourseEnrollment.objects.filter(course_filter) + + if not include_staff: + queryset = queryset.filter( + ~check_staff_exist_queryset(ref_user_id='user_id', ref_org='course__org', ref_course_id='course_id'), + ) + + queryset = queryset.annotate( + certificate_available=Exists( + GeneratedCertificate.objects.filter( + user_id=OuterRef('user_id'), + course_id=OuterRef('course_id'), + status='downloadable' + ) + ) + ).annotate( + course_score=Subquery( + PersistentCourseGrade.objects.filter( + user_id=OuterRef('user_id'), + course_id=OuterRef('course_id') + ).values('percent_grade')[:1] + ) + ).annotate( + active_in_course=Case( + When( + Exists( + StudentModule.objects.filter( + student_id=OuterRef('user_id'), + course_id=OuterRef('course_id'), + modified__gte=timezone.now() - timedelta(days=30) + ) + ), + then=Value(True), + ), + default=Value(False), + output_field=BooleanField(), + ) + ).select_related('user', 'user__profile') + return queryset diff --git a/futurex_openedx_extensions/dashboard/serializers.py b/futurex_openedx_extensions/dashboard/serializers.py index c4197606..b5cdbdd8 100644 --- a/futurex_openedx_extensions/dashboard/serializers.py +++ b/futurex_openedx_extensions/dashboard/serializers.py @@ -4,6 +4,7 @@ import re from typing import Any, Dict, Tuple +from common.djangoapps.student.models import CourseEnrollment from django.contrib.auth import get_user_model from django.utils.timezone import now from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary @@ -81,15 +82,15 @@ class LearnerBasicDetailsSerializer(ModelSerializerOptionalFields): user_id = serializers.SerializerMethodField() full_name = serializers.SerializerMethodField() alternative_full_name = serializers.SerializerMethodField() - username = serializers.CharField() + username = serializers.SerializerMethodField() national_id = serializers.SerializerMethodField() - email = serializers.EmailField() + email = serializers.SerializerMethodField() mobile_no = serializers.SerializerMethodField() year_of_birth = serializers.SerializerMethodField() gender = serializers.SerializerMethodField() gender_display = serializers.SerializerMethodField() - date_joined = serializers.DateTimeField() - last_login = serializers.DateTimeField() + date_joined = serializers.SerializerMethodField() + last_login = serializers.SerializerMethodField() class Meta: model = get_user_model() @@ -117,6 +118,16 @@ def _is_english(text: str) -> bool: """ return all(ord(char) < 128 for char in text) + def _get_user(self, obj: Any = None) -> get_user_model | None: # pylint: disable=no-self-use + """ + Retrieve the associated user for the given object. + + This method can be overridden in child classes to provide a different + implementation for accessing the user, depending on how the user is + related to the object (e.g., `obj.user`, `obj.profile.user`, etc.). + """ + return obj + def _get_name(self, obj: Any, alternative: bool = False) -> str: """ Calculate the full name and alternative full name. We have two issues in the data: @@ -129,8 +140,8 @@ def _get_name(self, obj: Any, alternative: bool = False) -> str: :type alternative: bool :return: The full name or alternative full name. """ - first_name = obj.first_name.strip() - last_name = obj.last_name.strip() + first_name = self._get_user(obj).first_name.strip() # type: ignore + last_name = self._get_user(obj).last_name.strip() # type: ignore full_name = first_name or last_name if first_name and last_name and not (first_name == last_name and ' ' in first_name): @@ -166,20 +177,36 @@ def _get_arabic_name(self, obj: Any) -> str: arabic_full_name = ' '.join(filter(None, (arabic_first_name, arabic_last_name))) return (arabic_full_name or '').strip() - @staticmethod - def _get_profile_field(obj: get_user_model, field_name: str) -> Any: + def _get_profile_field(self: Any, obj: get_user_model, field_name: str) -> Any: """Get the profile field value.""" - return getattr(obj.profile, field_name) if hasattr(obj, 'profile') and obj.profile else None + user = self._get_user(obj) + return getattr(user.profile, field_name) if hasattr(user, 'profile') and user.profile else None - @staticmethod - def _get_extra_field(obj: get_user_model, field_name: str) -> Any: + def _get_extra_field(self: Any, obj: get_user_model, field_name: str) -> Any: """Get the extra field value.""" - return getattr(obj.extrainfo, field_name) if hasattr(obj, 'extrainfo') and obj.extrainfo else None + user = self._get_user(obj) + return getattr(user.extrainfo, field_name) if hasattr(user, 'extrainfo') and user.extrainfo else None def get_user_id(self, obj: get_user_model) -> Any: # pylint: disable=no-self-use """Return user ID.""" return obj.id + def get_email(self, obj: get_user_model) -> str: + """Return user ID.""" + return self._get_user(obj).email # type: ignore + + def get_username(self, obj: get_user_model) -> str: + """Return user ID.""" + return self._get_user(obj).username # type: ignore + + def get_date_joined(self, obj: Any) -> str | None: + date_joined = self._get_user(obj).date_joined # type: ignore + return date_joined.isoformat() if date_joined else None + + def get_last_login(self, obj: Any) -> str | None: + last_login = self._get_user(obj).last_login # type: ignore + return last_login.isoformat() if last_login else None + def get_national_id(self, obj: get_user_model) -> Any: """Return national ID.""" return self._get_extra_field(obj, 'national_id') @@ -209,85 +236,55 @@ def get_year_of_birth(self, obj: get_user_model) -> Any: return self._get_profile_field(obj, 'year_of_birth') -class LearnerDetailsSerializer(LearnerBasicDetailsSerializer): - """Serializer for learner details.""" - enrolled_courses_count = serializers.SerializerMethodField() - certificates_count = serializers.SerializerMethodField() - - class Meta: - model = get_user_model() - fields = LearnerBasicDetailsSerializer.Meta.fields + [ - 'enrolled_courses_count', - 'certificates_count', - ] - - def get_certificates_count(self, obj: get_user_model) -> Any: # pylint: disable=no-self-use - """Return certificates count.""" - return obj.certificates_count - - def get_enrolled_courses_count(self, obj: get_user_model) -> Any: # pylint: disable=no-self-use - """Return enrolled courses count.""" - return obj.courses_count - - -class LearnerDetailsForCourseSerializer(LearnerBasicDetailsSerializer): - """Serializer for learner details for a course.""" +class CourseScoreAndCertificateSerializer(ModelSerializerOptionalFields): + """ + Course Score and Certificate Details Serializer + """ + exam_scores = SerializerOptionalMethodField(field_tags=['exam_scores', 'csv_export']) certificate_available = serializers.BooleanField() course_score = serializers.DecimalField(max_digits=5, decimal_places=2) active_in_course = serializers.BooleanField() progress = SerializerOptionalMethodField(field_tags=['progress', 'csv_export']) certificate_url = SerializerOptionalMethodField(field_tags=['certificate_url', 'csv_export']) - exam_scores = SerializerOptionalMethodField(field_tags=['exam_scores', 'csv_export']) class Meta: - model = get_user_model() - fields = LearnerBasicDetailsSerializer.Meta.fields + [ + fields = [ 'certificate_available', 'course_score', 'active_in_course', 'progress', 'certificate_url', - 'exam_scores', + 'exam_scores' ] def __init__(self, *args: Any, **kwargs: Any): """Initialize the serializer.""" super().__init__(*args, **kwargs) - - self._course_id = CourseLocator.from_string(self.context.get('course_id')) self._is_exam_name_in_header = self.context.get('omit_subsection_name', '0') != '1' - self._grading_info: Dict[str, Any] = {} self._subsection_locations: Dict[str, Any] = {} - self.collect_grading_info() - def collect_grading_info(self) -> None: + def collect_grading_info(self, course_ids: list) -> None: """Collect the grading info.""" self._grading_info = {} self._subsection_locations = {} + index = 0 if not self.is_optional_field_requested('exam_scores'): return - - grading_context = grading_context_for_course(get_course_by_id(self._course_id)) - index = 0 - for assignment_type_name, subsection_infos in grading_context['all_graded_subsections_by_type'].items(): - for subsection_index, subsection_info in enumerate(subsection_infos, start=1): - header_enum = f' {subsection_index}' if len(subsection_infos) > 1 else '' - header_name = f'{assignment_type_name}{header_enum}' - if self.is_exam_name_in_header: - header_name += f': {subsection_info["subsection_block"].display_name}' - - self._grading_info[str(index)] = { - 'header_name': header_name, - 'location': str(subsection_info['subsection_block'].location), - } - self._subsection_locations[str(subsection_info['subsection_block'].location)] = str(index) - index += 1 - - @property - def course_id(self) -> CourseLocator: - """Get the course ID.""" - return self._course_id + for course_id in course_ids: + grading_context = grading_context_for_course(get_course_by_id(course_id)) + for assignment_type_name, subsection_infos in grading_context['all_graded_subsections_by_type'].items(): + for subsection_index, subsection_info in enumerate(subsection_infos, start=1): + header_enum = f' {subsection_index}' if len(subsection_infos) > 1 else '' + header_name = f'{assignment_type_name}{header_enum}' + if self.is_exam_name_in_header: + header_name += f': {subsection_info["subsection_block"].display_name}' + self._grading_info[str(index)] = { + 'header_name': header_name, + 'location': str(subsection_info['subsection_block'].location), + } + self._subsection_locations[str(subsection_info['subsection_block'].location)] = str(index) + index += 1 @property def is_exam_name_in_header(self) -> bool: @@ -304,31 +301,42 @@ def subsection_locations(self) -> Dict[str, Any]: """Get the subsection locations.""" return self._subsection_locations - def get_certificate_url(self, obj: get_user_model) -> Any: + def _get_course_id(self, obj: Any = None) -> Any: + """Get the course ID. Its helper method required for CourseScoreAndCertificateSerializer""" + raise NotImplementedError('Child class must implement _get_user method.') + + def _get_user(self, obj: Any = None) -> Any: + """Get the User. Its helper method required for CourseScoreAndCertificateSerializer""" + raise NotImplementedError('Child class must implement _get_course_id method.') + + def get_certificate_url(self, obj: Any) -> Any: + """Return the certificate URL.""" + return get_certificate_url( + self.context.get('request'), self._get_user(obj), self._get_course_id(obj) + ) + + def get_progress(self, obj: Any) -> Any: """Return the certificate URL.""" - return get_certificate_url(self.context.get('request'), obj, self.course_id) + progress_info = get_course_blocks_completion_summary( + self._get_course_id(obj), self._get_user(obj) + ) + total = progress_info['complete_count'] + progress_info['incomplete_count'] + progress_info['locked_count'] + return round(progress_info['complete_count'] / total, 4) if total else 0.0 - def get_exam_scores(self, obj: get_user_model) -> Dict[str, Tuple[float, float] | None]: + def get_exam_scores(self, obj: Any) -> Dict[str, Tuple[float, float] | None]: """Return exam scores.""" result: Dict[str, Tuple[float, float] | None] = {__index: None for __index in self.grading_info} grades = PersistentSubsectionGrade.objects.filter( - user_id=obj.id, - course_id=self.course_id, + user_id=self._get_user(obj).id, + course_id=self._get_course_id(obj), usage_key__in=self.subsection_locations.keys(), first_attempted__isnull=False, ).values('usage_key', 'earned_all', 'possible_all') for grade in grades: result[self.subsection_locations[str(grade['usage_key'])]] = (grade['earned_all'], grade['possible_all']) - return result - def get_progress(self, obj: get_user_model) -> Any: - """Return the certificate URL.""" - progress_info = get_course_blocks_completion_summary(self.course_id, obj) - total = progress_info['complete_count'] + progress_info['incomplete_count'] + progress_info['locked_count'] - return round(progress_info['complete_count'] / total, 4) if total else 0.0 - def to_representation(self, instance: Any) -> Any: """Return the representation of the instance.""" def _extract_exam_scores(representation_item: dict[str, Any]) -> None: @@ -340,12 +348,91 @@ def _extract_exam_scores(representation_item: dict[str, Any]) -> None: representation_item[possible_key] = score[1] if score else 'no attempt' representation = super().to_representation(instance) - _extract_exam_scores(representation) - return representation +class LearnerDetailsSerializer(LearnerBasicDetailsSerializer): + """Serializer for learner details.""" + enrolled_courses_count = serializers.SerializerMethodField() + certificates_count = serializers.SerializerMethodField() + + class Meta: + model = get_user_model() + fields = LearnerBasicDetailsSerializer.Meta.fields + [ + 'enrolled_courses_count', + 'certificates_count', + ] + + def get_certificates_count(self, obj: get_user_model) -> Any: # pylint: disable=no-self-use + """Return certificates count.""" + return obj.certificates_count + + def get_enrolled_courses_count(self, obj: get_user_model) -> Any: # pylint: disable=no-self-use + """Return enrolled courses count.""" + return obj.courses_count + + +class LearnerDetailsForCourseSerializer( + LearnerBasicDetailsSerializer, CourseScoreAndCertificateSerializer +): # pylint: disable=too-many-ancestors + """Serializer for learner details for a course.""" + + class Meta: + model = get_user_model() + fields = LearnerBasicDetailsSerializer.Meta.fields + CourseScoreAndCertificateSerializer.Meta.fields + + def __init__(self, *args: Any, **kwargs: Any): + """Initialize the serializer.""" + super().__init__(*args, **kwargs) + self._course_id = CourseLocator.from_string(self.context.get('course_id')) + self.collect_grading_info([self._course_id]) + + def _get_course_id(self, obj: Any = None) -> CourseLocator: + """Get the course ID. Its helper method required for CourseScoreAndCertificateSerializer""" + return self._course_id + + def _get_user(self, obj: Any = None) -> get_user_model: + """Get the User. Its helper method required for CourseScoreAndCertificateSerializer""" + return obj + + +class LearnerEnrollmentSerializer( + LearnerBasicDetailsSerializer, CourseScoreAndCertificateSerializer +): # pylint: disable=too-many-ancestors + """Serializer for learner enrollments""" + course_id = serializers.SerializerMethodField() + + class Meta: + model = CourseEnrollment + fields = ( + LearnerBasicDetailsSerializer.Meta.fields + + CourseScoreAndCertificateSerializer.Meta.fields + + ['course_id'] + ) + + def __init__(self, *args: Any, **kwargs: Any): + """Initialize the serializer.""" + super().__init__(*args, **kwargs) + course_ids = self.context.get('course_ids') + self.collect_grading_info(course_ids) + + def _get_course_id(self, obj: Any = None) -> CourseLocator | None: + """Get the course ID. Its helper method required for CourseScoreAndCertificateSerializer""" + return obj.course_id if obj else None + + def _get_user(self, obj: Any = None) -> get_user_model | None: + """ + Get the User. Its helper method required for CourseScoreAndCertificateSerializer. It also + plays important role for LearnerBasicDetailsSerializer + """ + return obj.user if obj else None + + def get_course_id(self, obj: Any) -> str: + """Get course id""" + return str(self._get_course_id(obj)) + + class LearnerDetailsExtendedSerializer(LearnerDetailsSerializer): # pylint: disable=too-many-ancestors """Serializer for extended learner details.""" city = serializers.SerializerMethodField() diff --git a/futurex_openedx_extensions/dashboard/urls.py b/futurex_openedx_extensions/dashboard/urls.py index 753d4f20..46178db6 100644 --- a/futurex_openedx_extensions/dashboard/urls.py +++ b/futurex_openedx_extensions/dashboard/urls.py @@ -24,6 +24,9 @@ re_path( fr'^api/fx/learners/v1/learners/{COURSE_ID_REGX}/$', views.LearnersDetailsForCourseView.as_view(), name='learners-course'), + re_path( + r'^api/fx/learners/v1/enrollments/$', + views.LearnersEnrollmentView.as_view(), name='learners-enrollements'), re_path( r'^api/fx/learners/v1/learner/' + settings.USERNAME_PATTERN + '/$', views.LearnerInfoView.as_view(), diff --git a/futurex_openedx_extensions/dashboard/views.py b/futurex_openedx_extensions/dashboard/views.py index a9caed3b..c379f3f2 100644 --- a/futurex_openedx_extensions/dashboard/views.py +++ b/futurex_openedx_extensions/dashboard/views.py @@ -25,6 +25,7 @@ from futurex_openedx_extensions.dashboard.details.learners import ( get_learner_info_queryset, get_learners_by_course_queryset, + get_learners_enrollments_queryset, get_learners_queryset, ) from futurex_openedx_extensions.dashboard.statistics.certificates import get_certificates_count @@ -431,6 +432,39 @@ def get_serializer_context(self) -> Dict[str, Any]: return context +class LearnersEnrollmentView(ListAPIView, FXViewRoleInfoMixin): + """View to get the list of learners for a course""" + serializer_class = serializers.LearnerEnrollmentSerializer + permission_classes = [FXHasTenantCourseAccess] + pagination_class = DefaultPagination + fx_view_name = 'learners_enrollment_details' + fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_view_description = 'api/fx/learners/v1/enrollments: Get the list of enrollemts' + + def get_queryset(self, *args: Any, **kwargs: Any) -> QuerySet: + """Get the list of learners for a course""" + course_ids = self.request.query_params.get('course_ids', '') + user_ids = self.request.query_params.get('user_ids', '') + course_ids_list = [ + course.strip() for course in course_ids.split(',') + ] if course_ids else None + user_ids_list = [ + int(user.strip()) for user in user_ids.split(',') if user.strip().isdigit() + ] if user_ids else None + return get_learners_enrollments_queryset( + user_ids=user_ids_list, + course_ids=course_ids_list, + include_staff=self.request.query_params.get('include_staff', '0') == '1' + ) + + def get_serializer_context(self) -> Dict[str, Any]: + """Get the serializer context""" + context = super().get_serializer_context() + context['course_ids'] = [course_enrollment.course_id for course_enrollment in self.get_queryset()] + context['omit_subsection_name'] = self.request.query_params.get('omit_subsection_name', '0') + return context + + class GlobalRatingView(APIView, FXViewRoleInfoMixin): """View to get the global rating""" permission_classes = [FXHasTenantCourseAccess] diff --git a/tests/test_dashboard/test_details/test_details_learners.py b/tests/test_dashboard/test_details/test_details_learners.py index b2b2dce3..0396e4c2 100644 --- a/tests/test_dashboard/test_details/test_details_learners.py +++ b/tests/test_dashboard/test_details/test_details_learners.py @@ -11,6 +11,7 @@ get_courses_count_for_learner_queryset, get_learner_info_queryset, get_learners_by_course_queryset, + get_learners_enrollments_queryset, get_learners_queryset, ) from futurex_openedx_extensions.helpers.exceptions import FXCodedException, FXExceptionCodes @@ -193,3 +194,37 @@ def test_get_learners_by_course_queryset_include_staff(base_data): # pylint: di queryset = get_learners_by_course_queryset('course-v1:ORG1+5+5', include_staff=True) assert queryset.count() == 5, 'unexpected test data' + + +@pytest.mark.django_db +def test_get_learners_enrollments_queryset_annotations(base_data): # pylint: disable=unused-argument + """Verify that get_learners_by_course_queryset returns the correct QuerySet.""" + PersistentCourseGrade.objects.create(user_id=15, course_id='course-v1:ORG1+5+5', percent_grade=0.67) + queryset = get_learners_enrollments_queryset(course_ids=['course-v1:ORG1+5+5'], user_ids=[15]) + assert queryset.count() == 1, 'unexpected test data' + assert queryset[0].certificate_available is not None, 'certificate_available should be in the queryset' + assert queryset[0].course_score == 0.67, \ + 'course_score should be in the queryset with value 0.67' + assert queryset[0].active_in_course is False, \ + 'active_in_course should be in the queryset with value True' + + enrollment = queryset[0] + enrollment.is_active = False + enrollment.save() + assert get_learners_enrollments_queryset(course_ids=['course-v1:ORG1+5+5'], user_ids=[15]).count() == 0, \ + 'only active enrollments should be filtered' + + +@pytest.mark.django_db +@pytest.mark.parametrize('course_ids, user_ids, include_staff, expected_count, usecase', [ + (['course-v1:ORG1+5+5'], None, False, 5, 'only course_ids'), + (['course-v1:ORG1+5+5'], None, True, 7, 'only course_ids and include_staff'), + (None, [15], False, 3, 'only user_ids'), + (['course-v1:ORG1+5+5'], [15], False, 1, 'user_ids and course_ids'), +]) +def test_get_learners_enrollments_queryset( + course_ids, user_ids, include_staff, expected_count, usecase +): + """Verify that get_learners_by_course_queryset returns the correct QuerySet.""" + queryset = get_learners_enrollments_queryset(user_ids=user_ids, course_ids=course_ids, include_staff=include_staff) + assert queryset.count() == expected_count, f'unexpected test data with {usecase}' diff --git a/tests/test_dashboard/test_serializers.py b/tests/test_dashboard/test_serializers.py index 4b1f3748..3e4a5f05 100644 --- a/tests/test_dashboard/test_serializers.py +++ b/tests/test_dashboard/test_serializers.py @@ -3,7 +3,7 @@ import pytest from cms.djangoapps.course_creators.models import CourseCreator -from common.djangoapps.student.models import CourseAccessRole, SocialLink, UserProfile +from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment, SocialLink, UserProfile from custom_reg_form.models import ExtraInfo from deepdiff import DeepDiff from django.contrib.auth import get_user_model @@ -17,12 +17,14 @@ from futurex_openedx_extensions.dashboard.serializers import ( CourseDetailsBaseSerializer, CourseDetailsSerializer, + CourseScoreAndCertificateSerializer, DataExportTaskSerializer, LearnerBasicDetailsSerializer, LearnerCoursesDetailsSerializer, LearnerDetailsExtendedSerializer, LearnerDetailsForCourseSerializer, LearnerDetailsSerializer, + LearnerEnrollmentSerializer, UserRolesSerializer, ) from futurex_openedx_extensions.helpers import constants as cs @@ -253,13 +255,45 @@ def test_learner_details_serializer(base_data): # pylint: disable=unused-argume @pytest.mark.django_db -@patch('futurex_openedx_extensions.dashboard.serializers.LearnerDetailsForCourseSerializer.collect_grading_info') -def test_learner_details_for_course_serializer(mock_collect, base_data,): # pylint: disable=unused-argument +def test_course_score_and_certificate_serializer_for_required_child_methods(): + """Verify that the CourseScoreAndCertificateSerializer for required child methods""" + class TestSerializer(CourseScoreAndCertificateSerializer): + """Serializer for learner details for a course.""" + class Meta: + model = get_user_model() + fields = CourseScoreAndCertificateSerializer.Meta.fields + + context = {'requested_optional_field_tags': ['certificate_url']} + qs = get_dummy_queryset() + with pytest.raises(NotImplementedError) as exc_info: + serializer = TestSerializer(qs, many=True, context=context) + assert len(serializer.data) == qs.count() + + assert str(exc_info.value) == 'Child class must implement _get_course_id method.' + TestSerializer._get_user = Mock(return_value=qs[0]) # pylint: disable=protected-access + with pytest.raises(NotImplementedError) as exc_info: + serializer = TestSerializer(qs, many=True, context=context) + assert len(serializer.data) == qs.count() + + assert str(exc_info.value) == 'Child class must implement _get_user method.' + TestSerializer._get_course_id = Mock() # pylint: disable=protected-access + serializer = TestSerializer(qs, many=True, context=context) + assert len(serializer.data) == qs.count() + + +@pytest.mark.django_db +@patch('futurex_openedx_extensions.dashboard.serializers.CourseScoreAndCertificateSerializer.collect_grading_info') +def test_learner_enrollments_serializer(mock_collect, base_data,): # pylint: disable=unused-argument """Verify that the LearnerDetailsForCourseSerializer returns the needed fields.""" - queryset = get_dummy_queryset() - serializer = LearnerDetailsForCourseSerializer(queryset, context={'course_id': 'course-v1:ORG2+1+1'}, many=True) + queryset = CourseEnrollment.objects.filter(user_id=10, course_id='course-v1:ORG3+1+1').annotate( + certificate_available=Value(True), + course_score=Value(0.67), + active_in_course=Value(True), + ) + serializer = LearnerEnrollmentSerializer(queryset, context={ + 'course_ids': ['course-v1:ORG3+1+1'] + }, many=True) mock_collect.assert_called_once() - data = serializer.data assert len(data) == 1 assert data[0]['certificate_available'] is True @@ -312,7 +346,6 @@ def test_learner_details_for_course_serializer_collect_grading_info( assert all(isinstance(value['location'], str) for _, value in serializer.grading_info.items()) assert all(isinstance(key, str) for key in serializer.subsection_locations) - assert not DeepDiff(serializer.grading_info, expected_grading_info, ignore_order=True) assert not DeepDiff(serializer.subsection_locations, { 'block-v1:ORG2+1+1+type@homework+block@1': '0', @@ -334,7 +367,7 @@ def test_learner_details_for_course_serializer_collect_grading_info_not_used( assert not serializer.grading_info assert not serializer.subsection_locations - serializer.collect_grading_info() + serializer.collect_grading_info(['course-v1:ORG2+1+1']) assert not serializer.grading_info assert not serializer.subsection_locations diff --git a/tests/test_dashboard/test_views.py b/tests/test_dashboard/test_views.py index 9a762a31..07cc25ff 100644 --- a/tests/test_dashboard/test_views.py +++ b/tests/test_dashboard/test_views.py @@ -783,6 +783,31 @@ def test_success(self): self.assertGreater(len(response.data['results']), 0) +@pytest.mark.usefixtures('base_data') +class TestLearnersEnrollmentView(BaseTestViewMixin): + """Tests for LearnersEnrollmentView""" + VIEW_NAME = 'fx_dashboard:learners-enrollements' + + def test_unauthorized(self): + """Verify that the view returns 403 when the user is not authenticated""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, http_status.HTTP_403_FORBIDDEN) + + def test_permission_classes(self): + """Verify that the view has the correct permission classes""" + view_func, _, _ = resolve(self.url) + view_class = view_func.view_class + self.assertEqual(view_class.permission_classes, [FXHasTenantCourseAccess]) + + def test_success(self): + """Verify that the view returns the correct response""" + self.login_user(self.staff_user) + response = self.client.get(self.url) + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + response = self.client.get(self.url, {'course_ids': 'course-v1:ORG1+5+5', 'user_ids': 15}) + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + + class MockClickhouseQuery: """Mock ClickhouseQuery""" def __init__(