From 460c6c37f24bbd957bee9411b12c655031fcb39e Mon Sep 17 00:00:00 2001 From: Shadi Naif Date: Thu, 23 May 2024 18:54:55 +0300 Subject: [PATCH] feat: Add new API for learner information --- .../dashboard/details/learners.py | 36 +++++ .../dashboard/serializers.py | 108 +++++++++++++- futurex_openedx_extensions/dashboard/urls.py | 14 +- futurex_openedx_extensions/dashboard/views.py | 31 +++- futurex_openedx_extensions/helpers/tenants.py | 36 ++++- test_settings.py | 2 + .../common/djangoapps/student/models.py | 1 + .../edx_platform_mocks/fake_models/models.py | 26 ++++ .../fake_models/serializers.py | 15 ++ .../user_api/accounts/serializers.py | 2 + .../test_details/test_details_learners.py | 23 +++ tests/test_dashboard/test_serializers.py | 139 +++++++++++++++++- tests/test_dashboard/test_views.py | 80 +++++++++- tests/test_helpers/test_tenants.py | 53 +++++++ 14 files changed, 545 insertions(+), 21 deletions(-) create mode 100644 test_utils/edx_platform_mocks/fake_models/serializers.py create mode 100644 test_utils/edx_platform_mocks/openedx/core/djangoapps/user_api/accounts/serializers.py diff --git a/futurex_openedx_extensions/dashboard/details/learners.py b/futurex_openedx_extensions/dashboard/details/learners.py index b401e134..924feec3 100644 --- a/futurex_openedx_extensions/dashboard/details/learners.py +++ b/futurex_openedx_extensions/dashboard/details/learners.py @@ -133,3 +133,39 @@ def get_learners_queryset( ).select_related('profile').order_by('id') return queryset + + +def get_learner_info_queryset( + tenant_ids: List, user_id: int, visible_courses_filter: bool = True, active_courses_filter: bool = None +) -> QuerySet: + """ + Get the learner 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_courses_filter: Whether to only count courses that are visible in the catalog + :type visible_courses_filter: bool + :param active_courses_filter: Whether to only count active courses + :type active_courses_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_user_model().objects.filter(id=user_id).annotate( + courses_count=get_courses_count_for_learner_queryset( + course_org_filter_list, + visible_courses_filter=visible_courses_filter, + active_courses_filter=active_courses_filter, + ) + ).annotate( + certificates_count=get_certificates_count_for_learner_queryset( + course_org_filter_list, + visible_courses_filter=visible_courses_filter, + active_courses_filter=active_courses_filter, + ) + ).select_related('profile') + + return queryset diff --git a/futurex_openedx_extensions/dashboard/serializers.py b/futurex_openedx_extensions/dashboard/serializers.py index 10acfdf0..064cc8dc 100644 --- a/futurex_openedx_extensions/dashboard/serializers.py +++ b/futurex_openedx_extensions/dashboard/serializers.py @@ -1,7 +1,10 @@ """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 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 @@ -12,11 +15,13 @@ class LearnerDetailsSerializer(serializers.ModelSerializer): """Serializer for learner details.""" user_id = serializers.SerializerMethodField() full_name = serializers.SerializerMethodField() + alternative_full_name = serializers.SerializerMethodField() username = serializers.CharField() email = serializers.EmailField() mobile_no = serializers.SerializerMethodField() year_of_birth = serializers.SerializerMethodField() gender = serializers.SerializerMethodField() + gender_display = serializers.SerializerMethodField() date_joined = serializers.DateTimeField() last_login = serializers.DateTimeField() enrolled_courses_count = serializers.SerializerMethodField() @@ -27,17 +32,51 @@ class Meta: fields = [ "user_id", "full_name", + "alternative_full_name", "username", "email", "mobile_no", "year_of_birth", "gender", + "gender_display", "date_joined", "last_login", "enrolled_courses_count", "certificates_count", ] + def _get_names(self, obj, alternative=False): + """ + Calculate the full name and alternative full name. We have two issues in the data: + 1. The first and last names in auth.user contain many records with identical values (redundant data). + 2. The name field in the profile sometimes contains data while the first and last names are empty. + """ + first_name = obj.first_name.strip() + last_name = obj.last_name.strip() + alt_name = (self._get_profile_field(obj, "name") or "").strip() + + if not last_name: + full_name = first_name + elif not first_name: + full_name = last_name + elif first_name == last_name and " " in first_name: + full_name = first_name + else: + full_name = f"{first_name} {last_name}" + + if alt_name == full_name: + alt_name = "" + + if not full_name and alt_name: + full_name = alt_name + alt_name = "" + + if alt_name and ord(alt_name[0]) > 127 >= ord(full_name[0]): + names = alt_name, full_name + else: + names = full_name, alt_name + return names[0] if not alternative else names[1] + @staticmethod def _get_profile_field(obj, field_name): """Get the profile field value.""" @@ -49,7 +88,11 @@ def get_user_id(self, obj): # pylint: disable=no-self-use def get_full_name(self, obj): """Return full name.""" - return self._get_profile_field(obj, "name") + return self._get_names(obj) + + def get_alternative_full_name(self, obj): + """Return alternative full name.""" + return self._get_names(obj, alternative=True) def get_mobile_no(self, obj): """Return mobile number.""" @@ -59,6 +102,10 @@ def get_gender(self, obj): """Return gender.""" return self._get_profile_field(obj, "gender") + def get_gender_display(self, obj): + """Return readable text for gender""" + return self._get_profile_field(obj, "gender_display") + def get_certificates_count(self, obj): # pylint: disable=no-self-use """Return certificates count.""" return obj.certificates_count @@ -72,6 +119,65 @@ def get_year_of_birth(self, obj): return self._get_profile_field(obj, "year_of_birth") +class LearnerDetailsExtendedSerializer(LearnerDetailsSerializer): + """Serializer for extended learner details.""" + city = serializers.SerializerMethodField() + bio = serializers.SerializerMethodField() + level_of_education = serializers.SerializerMethodField() + social_links = serializers.SerializerMethodField() + image = serializers.SerializerMethodField() + profile_link = serializers.SerializerMethodField() + + class Meta: + model = get_user_model() + fields = LearnerDetailsSerializer.Meta.fields + [ + "city", + "bio", + "level_of_education", + "social_links", + "image", + "profile_link", + ] + + def get_city(self, obj): + """Return city.""" + return self._get_profile_field(obj, "city") + + def get_bio(self, obj): + """Return bio.""" + return self._get_profile_field(obj, "bio") + + def get_level_of_education(self, obj): + """Return level of education.""" + return self._get_profile_field(obj, "level_of_education_display") + + def get_social_links(self, obj): # pylint: disable=no-self-use + """Return social links.""" + result = {} + profile = obj.profile if hasattr(obj, "profile") else None + if profile: + links = profile.social_links.all().order_by('platform') + for link in links: + result[link.platform] = link.social_link + return result + + def get_image(self, obj): + """Return image.""" + if hasattr(obj, "profile") and obj.profile: + return AccountLegacyProfileSerializer.get_profile_image( + obj.profile, obj, self.context.get('request') + )["image_url_large"] + + return None + + 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 + + class CourseDetailsSerializer(serializers.ModelSerializer): """Serializer for course details.""" status = serializers.SerializerMethodField() diff --git a/futurex_openedx_extensions/dashboard/urls.py b/futurex_openedx_extensions/dashboard/urls.py index 292af796..ed651d93 100644 --- a/futurex_openedx_extensions/dashboard/urls.py +++ b/futurex_openedx_extensions/dashboard/urls.py @@ -1,6 +1,7 @@ """ URLs for dashboard. """ +from django.conf import settings from django.urls import re_path from futurex_openedx_extensions.dashboard import views @@ -8,8 +9,13 @@ app_name = 'fx_dashboard' urlpatterns = [ - re_path(r'^api/fx/courses/v1/courses', views.CoursesView.as_view(), name='courses'), - re_path(r'^api/fx/learners/v1/learners', views.LearnersView.as_view(), name='learners'), - 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'), + re_path(r'^api/fx/courses/v1/courses/$', views.CoursesView.as_view(), name='courses'), + re_path(r'^api/fx/learners/v1/learners/$', views.LearnersView.as_view(), name='learners'), + re_path( + r'^api/fx/learners/v1/learner/' + settings.USERNAME_PATTERN + '/$', + views.LearnerInfoView.as_view(), + name='learner-info' + ), + 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 a620dd9c..2680cdd7 100644 --- a/futurex_openedx_extensions/dashboard/views.py +++ b/futurex_openedx_extensions/dashboard/views.py @@ -4,9 +4,9 @@ from rest_framework.response import Response 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.learners import get_learners_queryset -from futurex_openedx_extensions.dashboard.serializers import CourseDetailsSerializer, LearnerDetailsSerializer +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 from futurex_openedx_extensions.dashboard.statistics.learners import get_learners_count @@ -15,7 +15,7 @@ from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter from futurex_openedx_extensions.helpers.pagination import DefaultPagination from futurex_openedx_extensions.helpers.permissions import HasTenantAccess -from futurex_openedx_extensions.helpers.tenants import get_selected_tenants +from futurex_openedx_extensions.helpers.tenants import get_selected_tenants, get_user_id_from_username_tenants class TotalCountsView(APIView): @@ -96,7 +96,7 @@ def get(self, request, *args, **kwargs): class LearnersView(ListAPIView): """View to get the list of learners""" - serializer_class = LearnerDetailsSerializer + serializer_class = serializers.LearnerDetailsSerializer permission_classes = [HasTenantAccess] pagination_class = DefaultPagination @@ -112,7 +112,7 @@ def get_queryset(self): class CoursesView(ListAPIView): """View to get the list of courses""" - serializer_class = CourseDetailsSerializer + serializer_class = serializers.CourseDetailsSerializer permission_classes = [HasTenantAccess] pagination_class = DefaultPagination filter_backends = [DefaultOrderingFilter] @@ -162,3 +162,24 @@ def get(self, request, *args, **kwargs): result = get_courses_count_by_status(tenant_ids=tenant_ids) return JsonResponse(self.to_json(result)) + + +class LearnerInfoView(APIView): + """View to get the information of a learner""" + permission_classes = [HasTenantAccess] + + def get(self, request, username, *args, **kwargs): # pylint: disable=no-self-use + """ + GET /api/fx/learners/v1/learner// + """ + 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) + + user = get_learner_info_queryset(tenant_ids, user_id).first() + + return JsonResponse( + serializers.LearnerDetailsExtendedSerializer(user, context={'request': request}).data + ) diff --git a/futurex_openedx_extensions/helpers/tenants.py b/futurex_openedx_extensions/helpers/tenants.py index 3c9ba138..bac9b648 100644 --- a/futurex_openedx_extensions/helpers/tenants.py +++ b/futurex_openedx_extensions/helpers/tenants.py @@ -3,14 +3,15 @@ from typing import Any, Dict, List -from common.djangoapps.student.models import CourseAccessRole +from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment from django.contrib.auth import get_user_model from django.db.models import Exists, OuterRef -from django.db.models.query import QuerySet +from django.db.models.query import Q, QuerySet from eox_tenant.models import Route, TenantConfig from rest_framework.request import Request from futurex_openedx_extensions.helpers.converters import error_details_to_dictionary, ids_string_to_list +from futurex_openedx_extensions.helpers.querysets import get_has_site_login_queryset TENANT_LIMITED_ADMIN_ROLES = ['org_course_creator_group'] @@ -273,3 +274,34 @@ def get_tenants_sites(tenant_ids: List[int]) -> List[str]: if site := get_tenant_site(tenant_id): tenant_sites.append(site) return tenant_sites + + +def get_user_id_from_username_tenants(username: str, tenant_ids: List[int]) -> int: + """ + Check if the given username is in any of the given tenants. Returns the user ID if found, and zero otherwise. + + :param username: The username to check + :type username: str + :param tenant_ids: List of tenant IDs to check + :type tenant_ids: List[int] + :return: The user ID if found, and zero otherwise + :rtype: int + """ + if not tenant_ids or not username: + return 0 + + course_org_filter_list = get_course_org_filter_list(tenant_ids)['course_org_filter_list'] + tenant_sites = get_tenants_sites(tenant_ids) + + user_id = get_user_model().objects.filter(username=username).annotate( + courseenrollment_count=Exists( + CourseEnrollment.objects.filter( + user_id=OuterRef('id'), + course__org__in=course_org_filter_list, + ) + ) + ).annotate( + has_site_login=get_has_site_login_queryset(tenant_sites) + ).filter(Q(courseenrollment_count=True) | Q(has_site_login=True)).values_list('id', flat=True) + + return user_id[0] if user_id else 0 diff --git a/test_settings.py b/test_settings.py index ca421fcb..b3866aef 100644 --- a/test_settings.py +++ b/test_settings.py @@ -70,3 +70,5 @@ def root(*args): # Avoid warnings about migrations DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + +USERNAME_PATTERN = r'(?P[\w.@+-]+)' diff --git a/test_utils/edx_platform_mocks/common/djangoapps/student/models.py b/test_utils/edx_platform_mocks/common/djangoapps/student/models.py index d6cdc322..b0edf594 100644 --- a/test_utils/edx_platform_mocks/common/djangoapps/student/models.py +++ b/test_utils/edx_platform_mocks/common/djangoapps/student/models.py @@ -2,6 +2,7 @@ from fake_models.models import ( # pylint: disable=unused-import CourseAccessRole, CourseEnrollment, + SocialLink, UserProfile, UserSignupSource, ) diff --git a/test_utils/edx_platform_mocks/fake_models/models.py b/test_utils/edx_platform_mocks/fake_models/models.py index 0bd01ffb..c1dcce0e 100644 --- a/test_utils/edx_platform_mocks/fake_models/models.py +++ b/test_utils/edx_platform_mocks/fake_models/models.py @@ -83,6 +83,9 @@ class UserProfile(models.Model): ) profile_image_uploaded_at = models.DateTimeField(null=True, blank=True) phone_number = models.CharField(blank=True, null=True, max_length=50) + bio = models.CharField(blank=True, null=True, max_length=3000, db_index=False) + level_of_education = models.CharField(blank=True, null=True, max_length=6, db_index=True) + city = models.TextField(blank=True, null=True) @property def has_profile_image(self): @@ -92,11 +95,34 @@ def has_profile_image(self): """ return self.profile_image_uploaded_at is not None + @property + def gender_display(self): + """ Convenience method that returns the human readable gender. """ + if self.gender: + return 'Male' if self.gender == 'm' else 'Female' + return None + + @property + def level_of_education_display(self): + """ Convenience method that returns the human readable level of education. """ + return self.level_of_education + class Meta: app_label = "fake_models" db_table = "auth_userprofile" +class SocialLink(models.Model): + """Mock""" + user_profile = models.ForeignKey(UserProfile, db_index=True, related_name='social_links', on_delete=models.CASCADE) + platform = models.CharField(max_length=30) + social_link = models.CharField(max_length=100, blank=True) + + class Meta: + app_label = "fake_models" + db_table = "student_social_link" + + class BaseFeedback(models.Model): """Mock""" RATING_OPTIONS = [ diff --git a/test_utils/edx_platform_mocks/fake_models/serializers.py b/test_utils/edx_platform_mocks/fake_models/serializers.py new file mode 100644 index 00000000..30bef726 --- /dev/null +++ b/test_utils/edx_platform_mocks/fake_models/serializers.py @@ -0,0 +1,15 @@ +"""edx-platform models mocks for testing purposes.""" + + +class AccountLegacyProfileSerializer: # pylint: disable=too-few-public-methods + """AccountLegacyProfileSerializer Mock""" + @staticmethod + def get_profile_image(profile, user, request): # pylint: disable=unused-argument + """Return profile image.""" + return { + "has_image": user.id == 1, + "image_url_full": "https://example.com/image_full.jpg", + "image_url_large": "https://example.com/image_large.jpg", + "image_url_medium": "https://example.com/image_medium.jpg", + "image_url_small": "https://example.com/image_small.jpg", + } diff --git a/test_utils/edx_platform_mocks/openedx/core/djangoapps/user_api/accounts/serializers.py b/test_utils/edx_platform_mocks/openedx/core/djangoapps/user_api/accounts/serializers.py new file mode 100644 index 00000000..4b77aad2 --- /dev/null +++ b/test_utils/edx_platform_mocks/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -0,0 +1,2 @@ +"""edx-platform Mocks""" +from fake_models.serializers import AccountLegacyProfileSerializer # pylint: disable=unused-import diff --git a/tests/test_dashboard/test_details/test_details_learners.py b/tests/test_dashboard/test_details/test_details_learners.py index e7da8b2c..dbf1f1b3 100644 --- a/tests/test_dashboard/test_details/test_details_learners.py +++ b/tests/test_dashboard/test_details/test_details_learners.py @@ -1,10 +1,13 @@ """Tests for learner details collectors""" +from unittest.mock import patch + import pytest from django.contrib.auth import get_user_model from futurex_openedx_extensions.dashboard.details.learners import ( get_certificates_count_for_learner_queryset, get_courses_count_for_learner_queryset, + get_learner_info_queryset, get_learners_queryset, ) @@ -39,6 +42,26 @@ def test_count_for_learner_queryset( assert queryset.all()[0].result == expected_count, f"{assert_error_message} +. Check the test data for details." +@pytest.mark.django_db +def test_get_learner_info_queryset(base_data): # pylint: disable=unused-argument + """Verify that get_learners_queryset returns the correct QuerySet.""" + queryset = get_learner_info_queryset([1], 3) + assert queryset.count() == 1, "bad test data, user id (3) should be in the queryset" + + info = queryset.first() + assert info.username == "user3", "invalid data fetched!" + assert hasattr(info, "courses_count"), "courses_count should be in the queryset" + assert hasattr(info, "certificates_count"), "certificates_count should be in the queryset" + + +@pytest.mark.django_db +def test_get_learner_info_queryset_selecting_profile(base_data): # pylint: disable=unused-argument + """Verify that get_learners_queryset returns the correct QuerySet along with the related profile record.""" + with patch('django.db.models.query.QuerySet.select_related') as mocked_select_related: + get_learner_info_queryset([1], 3) + mocked_select_related.assert_called_once_with('profile') + + @pytest.mark.django_db @pytest.mark.parametrize("tenant_ids, search_text, expected_count", [ ([7, 8], None, 22), diff --git a/tests/test_dashboard/test_serializers.py b/tests/test_dashboard/test_serializers.py index 93200072..4c92b964 100644 --- a/tests/test_dashboard/test_serializers.py +++ b/tests/test_dashboard/test_serializers.py @@ -1,35 +1,40 @@ """Test serializers for dashboard app""" +from unittest.mock import Mock + import pytest -from common.djangoapps.student.models import UserProfile +from common.djangoapps.student.models import SocialLink, UserProfile from django.contrib.auth import get_user_model from django.db.models import Count +from openedx.core.djangoapps.user_api.accounts.serializers import AccountLegacyProfileSerializer -from futurex_openedx_extensions.dashboard.serializers import LearnerDetailsSerializer +from futurex_openedx_extensions.dashboard.serializers import LearnerDetailsExtendedSerializer, LearnerDetailsSerializer -def get_dummy_queryset(): +def get_dummy_queryset(users_list=None): """Get a dummy queryset for testing.""" - return get_user_model().objects.filter(id__in=[10]).annotate( + if users_list is None: + users_list = [10] + return get_user_model().objects.filter(id__in=users_list).annotate( courses_count=Count('id'), certificates_count=Count('id'), ).select_related('profile') @pytest.mark.django_db -def test_learner_details_serializer_no_profile(): +def test_learner_details_serializer_no_profile(base_data): # pylint: disable=unused-argument """Verify that the LearnerDetailsSerializer is correctly defined.""" queryset = get_dummy_queryset() data = LearnerDetailsSerializer(queryset, many=True).data assert len(data) == 1 assert data[0]['user_id'] == 10 - assert data[0]['full_name'] is None + assert data[0]['full_name'] == "" assert data[0]['mobile_no'] is None assert data[0]['year_of_birth'] is None assert data[0]['gender'] is None @pytest.mark.django_db -def test_learner_details_serializer_with_profile(): +def test_learner_details_serializer_with_profile(base_data): # pylint: disable=unused-argument """Verify that the LearnerDetailsSerializer processes the profile fields.""" UserProfile.objects.create( user_id=10, @@ -46,3 +51,123 @@ def test_learner_details_serializer_with_profile(): assert data[0]['mobile_no'] == '1234567890' assert data[0]['year_of_birth'] == 1988 assert data[0]['gender'] == 'm' + + +@pytest.mark.django_db +@pytest.mark.parametrize("first_name, last_name, profile_name, expected_full_name, expected_alt_name, use_case", [ + ("", "", "", "", "", "all are empty"), + ("", "Doe", "Alt John", "Doe", "Alt John", "first name empty"), + ("John", "", "Alt John", "John", "Alt John", "last name empty"), + ("John", "Doe", "", "John Doe", "", "profile name empty"), + ("", "", "Alt John", "Alt John", "", "first and last names empty"), + ("John", "John", "Alt John", "John John", "Alt John", "first and last names identical with no spaces"), + ("John Doe", "John Doe", "Alt John", "John Doe", "Alt John", "first and last names identical with spaces"), + ("عربي", "Doe", "Alt John", "عربي Doe", "Alt John", "Arabic name"), + ("John", "Doe", "عربي", "عربي", "John Doe", "Arabic alternative name"), +]) +def test_learner_details_serializer_full_name_alt_name( + base_data, first_name, last_name, profile_name, expected_full_name, expected_alt_name, use_case +): # pylint: disable=unused-argument, too-many-arguments + """Verify that the LearnerDetailsSerializer processes names as expected.""" + queryset = get_dummy_queryset() + UserProfile.objects.create( + user_id=10, + name=profile_name, + ) + user = queryset.first() + user.first_name = first_name + user.last_name = last_name + user.save() + + serializer = LearnerDetailsSerializer(queryset, many=True) + data = serializer.data + assert len(data) == 1 + assert data[0]['user_id'] == 10 + assert data[0]['full_name'] == expected_full_name, f"checking ({use_case}) failed" + assert data[0]['alternative_full_name'] == expected_alt_name, f"checking ({use_case}) failed" + + +@pytest.mark.django_db +def test_learner_details_extended_serializer(base_data): # pylint: disable=unused-argument + """Verify that the LearnerDetailsExtendedSerializer returns the correct data.""" + queryset = get_dummy_queryset() + profile = UserProfile.objects.create( + user_id=10, + city='Test City', + bio='Test Bio', + level_of_education='Test Level', + ) + data = LearnerDetailsExtendedSerializer(queryset, many=True).data + image_serialized = AccountLegacyProfileSerializer.get_profile_image(profile, queryset.first(), None) + assert len(data) == 1 + assert data[0]['user_id'] == 10 + assert data[0]['city'] == 'Test City' + assert data[0]['bio'] == 'Test Bio' + 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 image_serialized['has_image'] is False + + +@pytest.mark.django_db +def test_learner_details_extended_serializer_no_profile(base_data): # pylint: disable=unused-argument + """Verify that the LearnerDetailsExtendedSerializer returns the correct data when there is no profile.""" + queryset = get_dummy_queryset() + data = LearnerDetailsExtendedSerializer(queryset, many=True).data + assert len(data) == 1 + assert data[0]['user_id'] == 10 + assert data[0]['city'] is None + assert data[0]['bio'] is None + assert data[0]['level_of_education'] is None + assert data[0]['social_links'] == {} + assert data[0]['image'] is None + 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.""" + queryset = get_dummy_queryset() + profile = UserProfile.objects.create(user_id=10) + SocialLink.objects.create( + user_profile_id=profile.id, + platform='facebook', + social_link='https://facebook.com/test', + ) + data = LearnerDetailsExtendedSerializer(queryset, many=True).data + assert len(data) == 1 + assert data[0]['user_id'] == 10 + assert data[0]['social_links'] == {'facebook': 'https://facebook.com/test'} + + +@pytest.mark.django_db +def test_learner_details_extended_serializer_image(base_data): # pylint: disable=unused-argument + """Verify that the LearnerDetailsExtendedSerializer returns the profile image.""" + queryset = get_dummy_queryset([1]) + profile = UserProfile.objects.create(user_id=1) + data = LearnerDetailsExtendedSerializer(queryset, many=True).data + image_serialized = AccountLegacyProfileSerializer.get_profile_image(profile, queryset.first(), None) + assert len(data) == 1 + assert data[0]['user_id'] == 1 + assert data[0]['image'] == image_serialized['image_url_large'] + assert image_serialized['has_image'] is True diff --git a/tests/test_dashboard/test_views.py b/tests/test_dashboard/test_views.py index 035bd593..b5ef1d66 100644 --- a/tests/test_dashboard/test_views.py +++ b/tests/test_dashboard/test_views.py @@ -1,8 +1,9 @@ """Test views for the dashboard app""" import json -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest +from common.djangoapps.student.models import CourseAccessRole from django.contrib.auth import get_user_model from django.http import JsonResponse from django.urls import resolve, reverse @@ -10,6 +11,7 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from rest_framework.test import APITestCase +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 tests.base_test_data import expected_statistics @@ -20,9 +22,15 @@ class BaseTextViewMixin(APITestCase): VIEW_NAME = 'view name is not set!' def setUp(self): - self.url = reverse(self.VIEW_NAME) + """Setup""" + self.url_args = [] self.staff_user = 2 + @property + def url(self): + """Get the URL""" + return reverse(self.VIEW_NAME, args=self.url_args) + def login_user(self, user_id): """Helper to login user""" self.client.force_login(get_user_model().objects.get(id=user_id)) @@ -196,3 +204,71 @@ def test_success(self): "self_archived": 0, "self_upcoming": 0, }) + + +@pytest.mark.usefixtures('base_data') +class TesttLearnerInfoView(BaseTextViewMixin): + """Tests for CourseStatusesView""" + VIEW_NAME = 'fx_dashboard:learner-info' + + def setUp(self): + """Setup""" + super().setUp() + self.url_args = ['user10'] + + 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' + self.url_args = [user_name] + assert not get_user_model().objects.filter(username=user_name).exists(), 'bad test data' + + self.login_user(self.staff_user) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data, {'reason': 'User not found user10x', 'details': {}}) + + def _get_test_users(self, org3_admin_id, org3_learner_id): + """Helper to get test users for the test_not_staff_user test""" + 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.login_user(org3_admin_id) + + def test_org_admin_user_with_allowed_learner(self): + """Verify that the view returns 200 when the user is an admin on the learner's organization""" + self._get_test_users(4, 45) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_org_admin_user_with_not_allowed_learner(self): + """Verify that the view returns 404 when the user is an org admin but the learner belongs to another org""" + self._get_test_users(9, 45) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) diff --git a/tests/test_helpers/test_tenants.py b/tests/test_helpers/test_tenants.py index 48d689a8..f0a23920 100644 --- a/tests/test_helpers/test_tenants.py +++ b/tests/test_helpers/test_tenants.py @@ -1,6 +1,7 @@ """Tests for tenants helpers.""" import pytest +from common.djangoapps.student.models import CourseEnrollment, UserSignupSource from django.contrib.auth import get_user_model from eox_tenant.models import TenantConfig @@ -285,3 +286,55 @@ def test_get_tenants_sites_bad_tenants(base_data, tenant_ids): # pylint: disabl """Verify get_tenants_sites function.""" result = tenants.get_tenants_sites(tenant_ids) assert result is not None and len(result) == 0 + + +@pytest.mark.django_db +def test_get_user_id_from_username_tenants_non_existent_username(base_data): # pylint: disable=unused-argument + """Verify get_user_id_from_username_tenants function for non-existent username.""" + username = 'non_existent_username' + tenant_ids = [1, 2, 3, 7, 8] + assert not get_user_model().objects.filter(username=username).exists(), 'test data is not as expected' + assert TenantConfig.objects.filter(id__in=tenant_ids).count() == len(tenant_ids), 'test data is not as expected' + + assert tenants.get_user_id_from_username_tenants(username, tenant_ids) == 0 + + +@pytest.mark.django_db +@pytest.mark.parametrize("tenant_ids", [ + [], + None, + [99], +]) +def test_get_user_id_from_username_tenants_bad_tenant(base_data, tenant_ids): # pylint: disable=unused-argument + """Verify get_user_id_from_username_tenants function for non-existent tenant.""" + username = 'user1' + assert get_user_model().objects.filter(username=username).exists(), 'test data is not as expected' + + assert tenants.get_user_id_from_username_tenants(username, tenant_ids) == 0 + + +@pytest.mark.django_db +@pytest.mark.parametrize("username, tenant_ids, orgs, sites, is_enrolled, is_signup", [ + ('user15', [1], ['ORG1', 'ORG2'], ['s1.sample.com'], True, False), + ('user50', [7], ['ORG3'], ['s7.sample.com'], False, True), + ('user4', [1], ['ORG1', 'ORG2'], ['s1.sample.com'], True, True), +]) +def test_get_user_id_from_username_tenants( + base_data, username, tenant_ids, orgs, sites, is_enrolled, is_signup +): # pylint: disable=unused-argument, too-many-arguments + """Verify get_user_id_from_username_tenants function for a user enrolled in a course but not in the site signup.""" + username = 'user15' + tenant_ids = [1] + assert get_user_model().objects.filter(username=username).exists(), 'test data is not as expected' + assert TenantConfig.objects.filter(id__in=tenant_ids).count() == len(tenant_ids), 'test data is not as expected' + + assert CourseEnrollment.objects.filter( + user__username=username, + course__org__in=['ORG1', 'ORG2'], + ).exists(), 'test data is not as expected' + assert not UserSignupSource.objects.filter( + user__username=username, + site='s1.sample.com', + ).exists(), 'test data is not as expected' + + assert tenants.get_user_id_from_username_tenants(username, tenant_ids) == int(username[len("user"):])