Skip to content

Commit

Permalink
feat: Add new API for learner information
Browse files Browse the repository at this point in the history
  • Loading branch information
shadinaif committed May 24, 2024
1 parent 0fc52d0 commit 460c6c3
Show file tree
Hide file tree
Showing 14 changed files with 545 additions and 21 deletions.
36 changes: 36 additions & 0 deletions futurex_openedx_extensions/dashboard/details/learners.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
108 changes: 107 additions & 1 deletion futurex_openedx_extensions/dashboard/serializers.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
Expand All @@ -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."""
Expand All @@ -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."""
Expand All @@ -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
Expand All @@ -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()
Expand Down
14 changes: 10 additions & 4 deletions futurex_openedx_extensions/dashboard/urls.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
"""
URLs for dashboard.
"""
from django.conf import settings
from django.urls import re_path

from futurex_openedx_extensions.dashboard import views

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'),
]
31 changes: 26 additions & 5 deletions futurex_openedx_extensions/dashboard/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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

Expand All @@ -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]
Expand Down Expand Up @@ -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/<username>/
"""
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
)
36 changes: 34 additions & 2 deletions futurex_openedx_extensions/helpers/tenants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,5 @@ def root(*args):

# Avoid warnings about migrations
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'

USERNAME_PATTERN = r'(?P<username>[\w.@+-]+)'
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from fake_models.models import ( # pylint: disable=unused-import
CourseAccessRole,
CourseEnrollment,
SocialLink,
UserProfile,
UserSignupSource,
)
Loading

0 comments on commit 460c6c3

Please sign in to comment.