From be58b870edbd07654196416ead6b7a72ade25330 Mon Sep 17 00:00:00 2001 From: Shadi Naif Date: Sun, 26 May 2024 11:35:22 +0300 Subject: [PATCH] feat: Add new API for learner courses information --- futurex_openedx_extensions/__init__.py | 2 +- .../dashboard/details/courses.py | 79 +++++++- .../dashboard/serializers.py | 110 +++++++++-- futurex_openedx_extensions/dashboard/urls.py | 5 + futurex_openedx_extensions/dashboard/views.py | 21 +- .../helpers/converters.py | 8 + .../edx_platform_mocks/completion/models.py | 2 + .../fake_models/functions.py | 22 +++ .../edx_platform_mocks/fake_models/models.py | 26 +++ .../lms/djangoapps/certificates/api.py | 2 + .../lms/djangoapps/courseware/courses.py | 2 + .../lms/djangoapps/grades/api.py | 2 + .../djangoapps/content/block_structure/api.py | 2 + .../test_details/test_details_courses.py | 43 +++- tests/test_dashboard/test_serializers.py | 184 +++++++++++++++--- tests/test_dashboard/test_views.py | 104 +++++++--- tests/test_helpers/test_converters.py | 23 +++ 17 files changed, 561 insertions(+), 76 deletions(-) create mode 100644 test_utils/edx_platform_mocks/completion/models.py create mode 100644 test_utils/edx_platform_mocks/fake_models/functions.py create mode 100644 test_utils/edx_platform_mocks/lms/djangoapps/certificates/api.py create mode 100644 test_utils/edx_platform_mocks/lms/djangoapps/courseware/courses.py create mode 100644 test_utils/edx_platform_mocks/lms/djangoapps/grades/api.py create mode 100644 test_utils/edx_platform_mocks/openedx/core/djangoapps/content/block_structure/api.py diff --git a/futurex_openedx_extensions/__init__.py b/futurex_openedx_extensions/__init__.py index 5a04fb70..878c7759 100644 --- a/futurex_openedx_extensions/__init__.py +++ b/futurex_openedx_extensions/__init__.py @@ -1,3 +1,3 @@ """One-line description for README and other doc files.""" -__version__ = '0.2.1' +__version__ = '0.3.2' diff --git a/futurex_openedx_extensions/dashboard/details/courses.py b/futurex_openedx_extensions/dashboard/details/courses.py index d122afe7..3528a37c 100644 --- a/futurex_openedx_extensions/dashboard/details/courses.py +++ b/futurex_openedx_extensions/dashboard/details/courses.py @@ -4,7 +4,22 @@ from typing import List from common.djangoapps.student.models import CourseAccessRole -from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery, Sum +from completion.models import BlockCompletion +from django.db.models import ( + Case, + Count, + DateTimeField, + Exists, + F, + IntegerField, + Max, + OuterRef, + Q, + Subquery, + Sum, + Value, + When, +) from django.db.models.functions import Coalesce from django.db.models.query import QuerySet from eox_nelp.course_experience.models import FeedbackCourse @@ -103,3 +118,65 @@ def get_courses_queryset( ) return queryset + + +def get_learner_courses_info_queryset( + tenant_ids: List, user_id: int, visible_filter: bool = True, active_filter: bool = None +) -> QuerySet: + """ + Get the learner's courses queryset for the given user ID. This method assumes a valid user ID. + + :param tenant_ids: List of tenant IDs to get the learner for + :type tenant_ids: List + :param user_id: The user ID to get the learner for + :type user_id: int + :param visible_filter: Whether to only count courses that are visible in the catalog + :type visible_filter: bool + :param active_filter: Whether to only count active courses + :type active_filter: bool + :return: QuerySet of learners + :rtype: QuerySet + """ + course_org_filter_list = get_course_org_filter_list(tenant_ids)['course_org_filter_list'] + + queryset = get_base_queryset_courses( + course_org_filter_list, visible_filter=visible_filter, active_filter=active_filter, + ).filter( + courseenrollment__user_id=user_id, + courseenrollment__is_active=True, + ).annotate( + related_user_id=Value(user_id, output_field=IntegerField()), + ).annotate( + enrollment_date=Case( + When( + courseenrollment__user_id=user_id, + then=F('courseenrollment__created'), + ), + default=None, + output_field=DateTimeField(), + ) + ).annotate( + last_activity=Case( + When( + Exists( + BlockCompletion.objects.filter( + user_id=user_id, + context_key=OuterRef('id'), + ), + ), + then=Subquery( + BlockCompletion.objects.filter( + user_id=user_id, + context_key=OuterRef('id'), + ).values('context_key').annotate( + last_activity=Max('modified'), + ).values('last_activity'), + output_field=DateTimeField(), + ), + ), + default=F('enrollment_date'), + output_field=DateTimeField(), + ) + ) + + return queryset diff --git a/futurex_openedx_extensions/dashboard/serializers.py b/futurex_openedx_extensions/dashboard/serializers.py index 064cc8dc..8a896452 100644 --- a/futurex_openedx_extensions/dashboard/serializers.py +++ b/futurex_openedx_extensions/dashboard/serializers.py @@ -1,13 +1,17 @@ """Serializers for the dashboard details API.""" -from urllib.parse import urljoin - from django.contrib.auth import get_user_model from django.utils.timezone import now +from lms.djangoapps.certificates.api import get_certificates_for_user_by_course_keys +from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary +from lms.djangoapps.grades.api import CourseGradeFactory +from opaque_keys.edx.keys import CourseKey +from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.user_api.accounts.serializers import AccountLegacyProfileSerializer from rest_framework import serializers from futurex_openedx_extensions.helpers.constants import COURSE_STATUS_SELF_PREFIX, COURSE_STATUSES +from futurex_openedx_extensions.helpers.converters import relative_url_to_absolute_url from futurex_openedx_extensions.helpers.tenants import get_tenants_by_org @@ -75,6 +79,7 @@ def _get_names(self, obj, alternative=False): names = alt_name, full_name else: names = full_name, alt_name + return names[0] if not alternative else names[1] @staticmethod @@ -172,19 +177,12 @@ def get_image(self, obj): def get_profile_link(self, obj): """Return profile link.""" - request = self.context.get('request') - if request and hasattr(request, 'site') and request.site: - return urljoin(request.site.domain, f"/u/{obj.username}") - return None + return relative_url_to_absolute_url(f"/u/{obj.username}/", self.context.get('request')) -class CourseDetailsSerializer(serializers.ModelSerializer): +class CourseDetailsBaseSerializer(serializers.ModelSerializer): """Serializer for course details.""" status = serializers.SerializerMethodField() - rating = serializers.SerializerMethodField() - enrolled_count = serializers.IntegerField() - active_count = serializers.IntegerField() - certificates_count = serializers.IntegerField() start_date = serializers.SerializerMethodField() end_date = serializers.SerializerMethodField() start_enrollment_date = serializers.SerializerMethodField() @@ -201,10 +199,6 @@ class Meta: "id", "status", "self_paced", - "rating", - "enrolled_count", - "active_count", - "certificates_count", "start_date", "end_date", "start_enrollment_date", @@ -228,10 +222,6 @@ def get_status(self, obj): # pylint: disable=no-self-use return f'{COURSE_STATUS_SELF_PREFIX if obj.self_paced else ""}{status}' - def get_rating(self, obj): # pylint: disable=no-self-use - """Return the course rating.""" - return round(obj.rating_total / obj.rating_count if obj.rating_count else 0, 1) - def get_start_enrollment_date(self, obj): # pylint: disable=no-self-use """Return the start enrollment date.""" return obj.enrollment_start @@ -259,3 +249,85 @@ def get_end_date(self, obj): # pylint: disable=no-self-use def get_author_name(self, obj): # pylint: disable=unused-argument,no-self-use """Return the author name.""" return None + + +class CourseDetailsSerializer(CourseDetailsBaseSerializer): + """Serializer for course details.""" + rating = serializers.SerializerMethodField() + enrolled_count = serializers.IntegerField() + active_count = serializers.IntegerField() + certificates_count = serializers.IntegerField() + + class Meta: + model = CourseOverview + fields = CourseDetailsBaseSerializer.Meta.fields + [ + "rating", + "enrolled_count", + "active_count", + "certificates_count", + ] + + def get_rating(self, obj): # pylint: disable=no-self-use + """Return the course rating.""" + return round(obj.rating_total / obj.rating_count if obj.rating_count else 0, 1) + + +class LearnerCoursesDetailsSerializer(CourseDetailsBaseSerializer): + """Serializer for learner's courses details.""" + enrollment_date = serializers.DateTimeField() + last_activity = serializers.DateTimeField() + certificate_url = serializers.SerializerMethodField() + progress_url = serializers.SerializerMethodField() + grades_url = serializers.SerializerMethodField() + progress = serializers.SerializerMethodField() + grade = serializers.SerializerMethodField() + + class Meta: + model = CourseOverview + fields = CourseDetailsBaseSerializer.Meta.fields + [ + "enrollment_date", + "last_activity", + "certificate_url", + "progress_url", + "grades_url", + "progress", + "grade", + ] + + def get_certificate_url(self, obj): # pylint: disable=no-self-use + """Return the certificate URL.""" + certificate = get_certificates_for_user_by_course_keys(obj.related_user_id, [obj.id]) + if certificate and obj.id in certificate: + return certificate[obj.id].get("download_url") + + return None + + def get_progress_url(self, obj): + """Return the certificate URL.""" + return relative_url_to_absolute_url( + f"/learning/course/{obj.id}/progress/{obj.related_user_id}/", + self.context.get('request') + ) + + def get_grades_url(self, obj): + """Return the certificate URL.""" + return relative_url_to_absolute_url( + f"/gradebook/{obj.id}/", + self.context.get('request') + ) + + def get_progress(self, obj): # pylint: disable=no-self-use + """Return the certificate URL.""" + return get_course_blocks_completion_summary(obj.id, obj.related_user_id) + + def get_grade(self, obj): # pylint: disable=no-self-use + """Return the certificate URL.""" + course_key = CourseKey.from_string(obj.id) + collected_block_structure = get_block_structure_manager(course_key).get_collected() + course_grade = CourseGradeFactory().read( + get_user_model().objects.get(id=obj.related_user_id), + collected_block_structure=collected_block_structure + ) + course_grade.update(visible_grades_only=True, has_staff_access=False) + + return course_grade diff --git a/futurex_openedx_extensions/dashboard/urls.py b/futurex_openedx_extensions/dashboard/urls.py index ed651d93..84e25ac7 100644 --- a/futurex_openedx_extensions/dashboard/urls.py +++ b/futurex_openedx_extensions/dashboard/urls.py @@ -16,6 +16,11 @@ views.LearnerInfoView.as_view(), name='learner-info' ), + re_path( + r'^api/fx/learners/v1/learner_courses/' + settings.USERNAME_PATTERN + '/$', + views.LearnerCoursesView.as_view(), + name='learner-courses' + ), re_path(r'^api/fx/statistics/v1/course_statuses/$', views.CourseStatusesView.as_view(), name='course-statuses'), re_path(r'^api/fx/statistics/v1/total_counts/$', views.TotalCountsView.as_view(), name='total-counts'), ] diff --git a/futurex_openedx_extensions/dashboard/views.py b/futurex_openedx_extensions/dashboard/views.py index 2680cdd7..2d954d38 100644 --- a/futurex_openedx_extensions/dashboard/views.py +++ b/futurex_openedx_extensions/dashboard/views.py @@ -5,7 +5,7 @@ from rest_framework.views import APIView from futurex_openedx_extensions.dashboard import serializers -from futurex_openedx_extensions.dashboard.details.courses import get_courses_queryset +from futurex_openedx_extensions.dashboard.details.courses import get_courses_queryset, get_learner_courses_info_queryset from futurex_openedx_extensions.dashboard.details.learners import get_learner_info_queryset, get_learners_queryset from futurex_openedx_extensions.dashboard.statistics.certificates import get_certificates_count from futurex_openedx_extensions.dashboard.statistics.courses import get_courses_count, get_courses_count_by_status @@ -183,3 +183,22 @@ def get(self, request, username, *args, **kwargs): # pylint: disable=no-self-us return JsonResponse( serializers.LearnerDetailsExtendedSerializer(user, context={'request': request}).data ) + + +class LearnerCoursesView(APIView): + """View to get the list of courses for a learner""" + permission_classes = [HasTenantAccess] + + def get(self, request, username, *args, **kwargs): # pylint: disable=no-self-use + """ + GET /api/fx/learners/v1/learner_courses// + """ + tenant_ids = get_selected_tenants(request) + user_id = get_user_id_from_username_tenants(username, tenant_ids) + + if not user_id: + return Response(error_details_to_dictionary(reason=f"User not found {username}"), status=404) + + courses = get_learner_courses_info_queryset(tenant_ids, user_id) + + return Response(serializers.LearnerCoursesDetailsSerializer(courses, many=True).data) diff --git a/futurex_openedx_extensions/helpers/converters.py b/futurex_openedx_extensions/helpers/converters.py index 15f9f30b..8935fa57 100644 --- a/futurex_openedx_extensions/helpers/converters.py +++ b/futurex_openedx_extensions/helpers/converters.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Any, List +from urllib.parse import urljoin def ids_string_to_list(ids_string: str) -> List[int]: @@ -17,3 +18,10 @@ def error_details_to_dictionary(reason: str, **details: Any) -> dict: "reason": reason, "details": details, } + + +def relative_url_to_absolute_url(relative_url: str, request: Any) -> str | None: + """Convert a relative URL to an absolute URL""" + if request and hasattr(request, 'site') and request.site: + return urljoin(request.site.domain, relative_url) + return None diff --git a/test_utils/edx_platform_mocks/completion/models.py b/test_utils/edx_platform_mocks/completion/models.py new file mode 100644 index 00000000..910a57d1 --- /dev/null +++ b/test_utils/edx_platform_mocks/completion/models.py @@ -0,0 +1,2 @@ +"""completion Mocks""" +from fake_models.models import BlockCompletion # pylint: disable=unused-import diff --git a/test_utils/edx_platform_mocks/fake_models/functions.py b/test_utils/edx_platform_mocks/fake_models/functions.py new file mode 100644 index 00000000..830ac873 --- /dev/null +++ b/test_utils/edx_platform_mocks/fake_models/functions.py @@ -0,0 +1,22 @@ +"""Mocks""" + + +def get_course_blocks_completion_summary(course_key, user): # pylint: disable=unused-argument + """get_course_blocks_completion_summary Mock""" + return None + + +def get_block_structure_manager(course_key): # pylint: disable=unused-argument + """get_block_structure_manager Mock""" + class Dummy: # pylint: disable=too-few-public-methods + """dummy class""" + def get_collected(self): # pylint: disable=no-self-use + """get_collected""" + return [] + + return Dummy() + + +def get_certificates_for_user_by_course_keys(user, course_keys): # pylint: disable=unused-argument + """get_certificates_for_user_by_course_keys Mock""" + return {} diff --git a/test_utils/edx_platform_mocks/fake_models/models.py b/test_utils/edx_platform_mocks/fake_models/models.py index c1dcce0e..2575d2ac 100644 --- a/test_utils/edx_platform_mocks/fake_models/models.py +++ b/test_utils/edx_platform_mocks/fake_models/models.py @@ -39,6 +39,7 @@ class CourseEnrollment(models.Model): user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) course = models.ForeignKey(CourseOverview, on_delete=models.CASCADE) is_active = models.BooleanField() + created = models.DateTimeField(auto_now_add=True) class Meta: app_label = "fake_models" @@ -153,3 +154,28 @@ class Meta: """Set constrain for author an course id""" unique_together = [["author", "course_id"]] db_table = "eox_nelp_feedbackcourse" + + +class BlockCompletion(models.Model): + """Mock""" + id = models.BigAutoField(primary_key=True) # pylint: disable=invalid-name + user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + context_key = models.CharField(max_length=255, null=False, blank=False, db_column="course_key") + modified = models.DateTimeField() + + +class CourseGradeFactory: # pylint: disable=too-few-public-methods + """Mock""" + def read(self, *args, **kwargs): # pylint: disable=no-self-use + """Mock read""" + class Dummy: + """dummy class""" + def update(self, *args, **kwargs): # pylint: disable=no-self-use + """update""" + return None + + def __iter__(self): + """__iter__""" + return iter([("letter_grade", "Fail"), ("percent", 0.4), ("is_passing", False)]) + + return Dummy() diff --git a/test_utils/edx_platform_mocks/lms/djangoapps/certificates/api.py b/test_utils/edx_platform_mocks/lms/djangoapps/certificates/api.py new file mode 100644 index 00000000..b8d457de --- /dev/null +++ b/test_utils/edx_platform_mocks/lms/djangoapps/certificates/api.py @@ -0,0 +1,2 @@ +"""edx-platform Mocks""" +from fake_models.functions import get_certificates_for_user_by_course_keys # pylint: disable=unused-import diff --git a/test_utils/edx_platform_mocks/lms/djangoapps/courseware/courses.py b/test_utils/edx_platform_mocks/lms/djangoapps/courseware/courses.py new file mode 100644 index 00000000..6c3eceb9 --- /dev/null +++ b/test_utils/edx_platform_mocks/lms/djangoapps/courseware/courses.py @@ -0,0 +1,2 @@ +"""edx-platform Mock""" +from fake_models.functions import get_course_blocks_completion_summary # pylint: disable=unused-import diff --git a/test_utils/edx_platform_mocks/lms/djangoapps/grades/api.py b/test_utils/edx_platform_mocks/lms/djangoapps/grades/api.py new file mode 100644 index 00000000..a2d405f3 --- /dev/null +++ b/test_utils/edx_platform_mocks/lms/djangoapps/grades/api.py @@ -0,0 +1,2 @@ +"""edx-platform Mock""" +from fake_models.models import CourseGradeFactory # pylint: disable=unused-import diff --git a/test_utils/edx_platform_mocks/openedx/core/djangoapps/content/block_structure/api.py b/test_utils/edx_platform_mocks/openedx/core/djangoapps/content/block_structure/api.py new file mode 100644 index 00000000..0c970e1c --- /dev/null +++ b/test_utils/edx_platform_mocks/openedx/core/djangoapps/content/block_structure/api.py @@ -0,0 +1,2 @@ +"""edx-platform Mocks""" +from fake_models.functions import get_block_structure_manager # pylint: disable=unused-import diff --git a/tests/test_dashboard/test_details/test_details_courses.py b/tests/test_dashboard/test_details/test_details_courses.py index 15a76f79..9559e5e4 100644 --- a/tests/test_dashboard/test_details/test_details_courses.py +++ b/tests/test_dashboard/test_details/test_details_courses.py @@ -1,9 +1,12 @@ """Tests for courses details collectors""" import pytest +from common.djangoapps.student.models import CourseEnrollment +from completion.models import BlockCompletion +from django.utils.timezone import now, timedelta from eox_nelp.course_experience.models import FeedbackCourse from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from futurex_openedx_extensions.dashboard.details.courses import get_courses_queryset +from futurex_openedx_extensions.dashboard.details.courses import get_courses_queryset, get_learner_courses_info_queryset @pytest.mark.django_db @@ -63,3 +66,41 @@ def test_get_courses_queryset_result_rating(base_data): # pylint: disable=unuse continue assert record.rating_count == len(ratings) assert record.rating_total == sum(ratings) + + +@pytest.mark.django_db +def test_get_learner_courses_info_queryset(base_data): # pylint: disable=unused-argument + """Verify that get_learner_courses_info_queryset returns the correct QuerySet.""" + user_id = 23 + now_datetime = now() + test_data = { + 'course-v1:ORG2+4+4': { + 'enrollment_date': now_datetime - timedelta(days=20), + 'activities': [4, 2, 7], + 'last_activity': now_datetime - timedelta(days=2), + }, + 'course-v1:ORG2+5+5': { + 'enrollment_date': now_datetime - timedelta(days=19), + 'activities': [], + 'last_activity': now_datetime - timedelta(days=19), + }, + } + for course_id, data in test_data.items(): + enrollment = CourseEnrollment.objects.get(user_id=user_id, course_id=course_id) + enrollment.created = data['enrollment_date'] + enrollment.save() + for days in data['activities']: + BlockCompletion.objects.create( + user_id=user_id, + context_key=course_id, + modified=now_datetime - timedelta(days=days), + ) + + result = get_learner_courses_info_queryset([1], user_id) + + assert result.count() == len(test_data) + for record in result: + assert record.id in test_data, f'failed for: {record.id}' + assert record.related_user_id == user_id, f'failed for: {record.id}' + assert record.enrollment_date == test_data[record.id]['enrollment_date'], f'failed for: {record.id}' + assert record.last_activity == test_data[record.id]['last_activity'], f'failed for: {record.id}' diff --git a/tests/test_dashboard/test_serializers.py b/tests/test_dashboard/test_serializers.py index 4c92b964..d85de52a 100644 --- a/tests/test_dashboard/test_serializers.py +++ b/tests/test_dashboard/test_serializers.py @@ -1,13 +1,22 @@ """Test serializers for dashboard app""" -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from common.djangoapps.student.models import SocialLink, UserProfile from django.contrib.auth import get_user_model from django.db.models import Count +from django.utils.timezone import get_current_timezone, now, timedelta +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.user_api.accounts.serializers import AccountLegacyProfileSerializer -from futurex_openedx_extensions.dashboard.serializers import LearnerDetailsExtendedSerializer, LearnerDetailsSerializer +from futurex_openedx_extensions.dashboard.serializers import ( + CourseDetailsBaseSerializer, + CourseDetailsSerializer, + LearnerCoursesDetailsSerializer, + LearnerDetailsExtendedSerializer, + LearnerDetailsSerializer, +) +from futurex_openedx_extensions.helpers.constants import COURSE_STATUS_SELF_PREFIX, COURSE_STATUSES def get_dummy_queryset(users_list=None): @@ -97,7 +106,8 @@ def test_learner_details_extended_serializer(base_data): # pylint: disable=unus bio='Test Bio', level_of_education='Test Level', ) - data = LearnerDetailsExtendedSerializer(queryset, many=True).data + request = Mock(site=Mock(domain="https://an-example.com")) + data = LearnerDetailsExtendedSerializer(queryset, many=True, context={"request": request}).data image_serialized = AccountLegacyProfileSerializer.get_profile_image(profile, queryset.first(), None) assert len(data) == 1 assert data[0]['user_id'] == 10 @@ -106,7 +116,7 @@ def test_learner_details_extended_serializer(base_data): # pylint: disable=unus assert data[0]['level_of_education'] == 'Test Level' assert data[0]['social_links'] == {} assert data[0]['image'] == image_serialized['image_url_large'] - assert data[0]['profile_link'] is None + assert data[0]['profile_link'] == "https://an-example.com/u/user10/" assert image_serialized['has_image'] is False @@ -125,25 +135,6 @@ def test_learner_details_extended_serializer_no_profile(base_data): # pylint: d assert data[0]['profile_link'] is None -@pytest.mark.django_db -@pytest.mark.parametrize("site, expected_value", [ - (None, None), - (Mock(domain='https://profile.example.com'), 'https://profile.example.com/u/user10'), -]) -def test_learner_details_extended_serializer_profile_link( - base_data, site, expected_value -): # pylint: disable=unused-argument - """Verify that the LearnerDetailsExtendedSerializer returns the profile link.""" - queryset = get_dummy_queryset() - UserProfile.objects.create(user_id=10) - data = LearnerDetailsExtendedSerializer( - queryset, many=True, context={'request': Mock(site=site)} - ).data - assert len(data) == 1 - assert data[0]['user_id'] == 10 - assert data[0]['profile_link'] == expected_value - - @pytest.mark.django_db def test_learner_details_extended_serializer_social_links(base_data): # pylint: disable=unused-argument """Verify that the LearnerDetailsExtendedSerializer returns the social links.""" @@ -171,3 +162,150 @@ def test_learner_details_extended_serializer_image(base_data): # pylint: disabl assert data[0]['user_id'] == 1 assert data[0]['image'] == image_serialized['image_url_large'] assert image_serialized['has_image'] is True + + +@pytest.mark.django_db +def test_course_details_base_serializer(base_data): # pylint: disable=unused-argument + """Verify that the CourseDetailsBaseSerializer is correctly defined.""" + course = CourseOverview.objects.first() + now_datetime = now() + course.enrollment_start = now_datetime - timedelta(days=10) + course.enrollment_end = now_datetime + timedelta(days=10) + course.start = now_datetime - timedelta(days=5) + course.end = now_datetime + timedelta(days=20) + course.course_image_url = 'https://example.com/image.jpg' + course.save() + + with patch('futurex_openedx_extensions.dashboard.serializers.get_tenants_by_org') as mock_get_tenants_by_org: + mock_get_tenants_by_org.return_value = [1, 2] + data = CourseDetailsBaseSerializer(course).data + + assert data['id'] == course.id + assert data['self_paced'] == course.self_paced + assert data['start_date'] == course.start + assert data['end_date'] == course.end + assert data['start_enrollment_date'] == course.enrollment_start + assert data['end_enrollment_date'] == course.enrollment_end + assert data['display_name'] == course.display_name + assert data['image_url'] == 'https://example.com/image.jpg' + assert data['org'] == course.org + assert data['tenant_ids'] == [1, 2] + + +@pytest.mark.django_db +@pytest.mark.parametrize("start_date, end_date, expected_status", [ + (None, None, COURSE_STATUSES["active"]), + (None, now() + timedelta(days=10), COURSE_STATUSES["active"]), + (now() - timedelta(days=10), None, COURSE_STATUSES["active"]), + (now() - timedelta(days=10), now() + timedelta(days=10), COURSE_STATUSES["active"]), + (now() - timedelta(days=10), now() - timedelta(days=5), COURSE_STATUSES["archived"]), + (now() + timedelta(days=10), now() + timedelta(days=20), COURSE_STATUSES["upcoming"]), + (now() + timedelta(days=10), None, COURSE_STATUSES["upcoming"]), +]) +def test_course_details_base_serializer_status( + base_data, start_date, end_date, expected_status +): # pylint: disable=unused-argument + """Verify that the CourseDetailsBaseSerializer returns the correct status.""" + course = CourseOverview.objects.first() + course.self_paced = False + course.start = start_date + course.end = end_date + course.save() + + data = CourseDetailsBaseSerializer(course).data + assert data['status'] == expected_status + + course.self_paced = True + course.save() + data = CourseDetailsBaseSerializer(course).data + assert data['status'] == f'{COURSE_STATUS_SELF_PREFIX}{expected_status}' + + +@pytest.mark.django_db +def test_course_details_serializer(base_data): # pylint: disable=unused-argument + """Verify that the CourseDetailsSerializer is correctly defined.""" + course = CourseOverview.objects.first() + course.rating_total = None + course.rating_count = None + course.enrolled_count = 10 + course.active_count = 5 + course.certificates_count = 3 + course.save() + data = CourseDetailsSerializer(course).data + assert data['id'] == course.id + assert data['enrolled_count'] == course.enrolled_count + assert data['active_count'] == course.active_count + assert data['certificates_count'] == course.certificates_count + + +@pytest.mark.django_db +@pytest.mark.parametrize("rating_total, rating_count, expected_rating", [ + (None, None, 0), + (10, 0, 0), + (0, 10, 0), + (15, 5, 3), + (17, 6, 2.8), + (170, 59, 2.9), +]) +def test_course_details_serializer_rating( + base_data, rating_total, rating_count, expected_rating +): # pylint: disable=unused-argument + """Verify that the CourseDetailsSerializer returns the correct rating.""" + assert rating_total is None or rating_total == int(rating_total), "bad test data, rating_total should be an integer" + assert rating_count is None or rating_count == int(rating_count), "bad test data, rating_count should be an integer" + + course = CourseOverview.objects.first() + course.rating_total = rating_total + course.rating_count = rating_count + course.enrolled_count = 1 + course.active_count = 1 + course.certificates_count = 1 + course.save() + data = CourseDetailsSerializer(course).data + assert data['rating'] == expected_rating + + +@pytest.mark.django_db +def test_learner_courses_details_serializer(base_data): # pylint: disable=unused-argument + """Verify that the LearnerCoursesDetailsSerializer is correctly defined.""" + enrollment_date = (now() - timedelta(days=10)).astimezone(get_current_timezone()) + last_activity = (now() - timedelta(days=5)).astimezone(get_current_timezone()) + + course = CourseOverview.objects.first() + course.enrollment_date = enrollment_date + course.last_activity = last_activity + course.related_user_id = 44 + + completion_summary = { + 'complete_count': 9, + 'incomplete_count': 3, + 'locked_count': 1, + } + + request = Mock(site=Mock(domain="https://test.com")) + with patch( + 'futurex_openedx_extensions.dashboard.serializers.get_course_blocks_completion_summary' + ) as mock_get_completion_summary: + with patch( + 'futurex_openedx_extensions.dashboard.serializers.get_certificates_for_user_by_course_keys' + ) as mock_get_certificates: + mock_get_completion_summary.return_value = completion_summary + mock_get_certificates.return_value = { + course.id: { + 'download_url': "https://test.com/courses/course-v1:dummy+key/certificate/", + } + } + data = LearnerCoursesDetailsSerializer(course, context={"request": request}).data + + assert data['id'] == course.id + assert data['enrollment_date'] == enrollment_date.isoformat() + assert data['last_activity'] == last_activity.isoformat() + assert data['progress_url'] == f"https://test.com/learning/course/{course.id}/progress/{course.related_user_id}/" + assert data['grades_url'] == f"https://test.com/gradebook/{course.id}/" + assert data['progress'] == completion_summary + assert dict(data['grade']) == { + "letter_grade": "Fail", + "percent": 0.4, + "is_passing": False, + } + assert data['certificate_url'] == "https://test.com/courses/course-v1:dummy+key/certificate/" diff --git a/tests/test_dashboard/test_views.py b/tests/test_dashboard/test_views.py index b5ef1d66..11df60c1 100644 --- a/tests/test_dashboard/test_views.py +++ b/tests/test_dashboard/test_views.py @@ -14,10 +14,11 @@ from futurex_openedx_extensions.dashboard import serializers from futurex_openedx_extensions.helpers.constants import COURSE_STATUSES from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter +from futurex_openedx_extensions.helpers.permissions import HasTenantAccess from tests.base_test_data import expected_statistics -class BaseTextViewMixin(APITestCase): +class BaseTestViewMixin(APITestCase): """Base test view mixin""" VIEW_NAME = 'view name is not set!' @@ -37,7 +38,7 @@ def login_user(self, user_id): @pytest.mark.usefixtures('base_data') -class TestTotalCountsView(BaseTextViewMixin): +class TestTotalCountsView(BaseTestViewMixin): """Tests for TotalCountsView""" VIEW_NAME = 'fx_dashboard:total-counts' @@ -78,7 +79,7 @@ def test_selected_tenants(self): @pytest.mark.usefixtures('base_data') -class TestLearnersView(BaseTextViewMixin): +class TestLearnersView(BaseTestViewMixin): """Tests for LearnersView""" VIEW_NAME = 'fx_dashboard:learners' @@ -111,7 +112,7 @@ def test_success(self): @pytest.mark.usefixtures('base_data') -class TesttCoursesView(BaseTextViewMixin): +class TestCoursesView(BaseTestViewMixin): """Tests for CoursesView""" VIEW_NAME = 'fx_dashboard:courses' @@ -174,7 +175,7 @@ def test_sorting(self): @pytest.mark.usefixtures('base_data') -class TesttCourseCourseStatusesView(BaseTextViewMixin): +class TestCourseCourseStatusesView(BaseTestViewMixin): """Tests for CourseStatusesView""" VIEW_NAME = 'fx_dashboard:course-statuses' @@ -206,37 +207,24 @@ def test_success(self): }) -@pytest.mark.usefixtures('base_data') -class TesttLearnerInfoView(BaseTextViewMixin): +class PermissionsTestOfLearnerInfoViewMixin: """Tests for CourseStatusesView""" - VIEW_NAME = 'fx_dashboard:learner-info' - def setUp(self): """Setup""" super().setUp() self.url_args = ['user10'] + 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, [HasTenantAccess]) + 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, 403) - def test_success(self): - """Verify that the view returns the correct response""" - user = get_user_model().objects.get(username='user10') - user.courses_count = 3 - user.certificates_count = 1 - self.url_args = [user.username] - - self.login_user(self.staff_user) - with patch('futurex_openedx_extensions.dashboard.views.get_learner_info_queryset') as mock_get_info: - mock_get_info.return_value = Mock(first=Mock(return_value=user)) - response = self.client.get(self.url) - - self.assertEqual(response.status_code, 200) - data = json.loads(response.content) - assert data == serializers.LearnerDetailsExtendedSerializer(user).data - def test_user_not_found(self): """Verify that the view returns 404 when the user is not found""" user_name = 'user10x' @@ -253,11 +241,11 @@ def _get_test_users(self, org3_admin_id, org3_learner_id): admin_user = get_user_model().objects.get(id=org3_admin_id) learner_user = get_user_model().objects.get(id=org3_learner_id) - assert not admin_user.is_staff, 'bad test data' - assert not admin_user.is_superuser, 'bad test data' - assert not learner_user.is_staff, 'bad test data' - assert not learner_user.is_superuser, 'bad test data' - assert not CourseAccessRole.objects.filter(user_id=org3_learner_id).exists(), 'bad test data' + self.assertFalse(admin_user.is_staff, msg='bad test data') + self.assertFalse(admin_user.is_superuser, msg='bad test data') + self.assertFalse(learner_user.is_staff, msg='bad test data') + self.assertFalse(learner_user.is_superuser, msg='bad test data') + self.assertFalse(CourseAccessRole.objects.filter(user_id=org3_learner_id).exists(), msg='bad test data') self.login_user(org3_admin_id) @@ -272,3 +260,59 @@ def test_org_admin_user_with_not_allowed_learner(self): self._get_test_users(9, 45) response = self.client.get(self.url) self.assertEqual(response.status_code, 404) + + +@pytest.mark.usefixtures('base_data') +class TestLearnerInfoView(PermissionsTestOfLearnerInfoViewMixin, BaseTestViewMixin): + """Tests for CourseStatusesView""" + VIEW_NAME = 'fx_dashboard:learner-info' + + def test_success(self): + """Verify that the view returns the correct response""" + user = get_user_model().objects.get(username='user10') + user.courses_count = 3 + user.certificates_count = 1 + self.url_args = [user.username] + self.assertFalse(()) + + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_learner_info_queryset') as mock_get_info: + mock_get_info.return_value = Mock(first=Mock(return_value=user)) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertDictEqual(data, serializers.LearnerDetailsExtendedSerializer(user).data) + + +@patch.object( + serializers.LearnerCoursesDetailsSerializer, + 'get_grade', + lambda self, obj: {"letter_grade": "Pass", "percent": 0.7, "is_passing": True} +) +@pytest.mark.usefixtures('base_data') +class TestLearnerCoursesDetailsView(PermissionsTestOfLearnerInfoViewMixin, BaseTestViewMixin): + """Tests for CourseStatusesView""" + VIEW_NAME = 'fx_dashboard:learner-courses' + + def test_success(self): + """Verify that the view returns the correct response""" + user = get_user_model().objects.get(username='user10') + self.url_args = [user.username] + + courses = CourseOverview.objects.filter(courseenrollment__user=user) + for course in courses: + course.enrollment_date = now() - timedelta(days=10) + course.last_activity = now() - timedelta(days=2) + course.related_user_id = user.id + course.save() + + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_learner_courses_info_queryset') as mock_get_info: + mock_get_info.return_value = courses + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertEqual(len(data), 2) + self.assertEqual(list(data), list(serializers.LearnerCoursesDetailsSerializer(courses, many=True).data)) diff --git a/tests/test_helpers/test_converters.py b/tests/test_helpers/test_converters.py index 6d0d6072..e98d6289 100644 --- a/tests/test_helpers/test_converters.py +++ b/tests/test_helpers/test_converters.py @@ -1,4 +1,7 @@ + """Tests for converters helpers.""" +from unittest.mock import Mock + import pytest from futurex_openedx_extensions.helpers import converters @@ -46,3 +49,23 @@ def test_error_details_to_dictionary(): 'anything': {'key': 'value'}, }, } + + +def test_relative_url_to_absolute_url_no_request(): + """Verify that relative_url_to_absolute_url return None when no request is provided.""" + assert converters.relative_url_to_absolute_url('/test', None) is None + + +def test_relative_url_to_absolute_url_no_site(): + """Verify that relative_url_to_absolute_url return None when no site is in the provided request.""" + request = Mock() + delattr(request, 'site') # pylint: disable=literal-used-as-attribute + assert not hasattr(request, 'site') + assert converters.relative_url_to_absolute_url('/test', request) is None + + +def test_relative_url_to_absolute_url_with_site(): + """Verify that relative_url_to_absolute_url return the correct absolute URL.""" + request = Mock() + request.site.domain = 'https://example-converter.com' + assert converters.relative_url_to_absolute_url('/test9', request) == 'https://example-converter.com/test9'