Skip to content

Commit

Permalink
feat: new API learners-of-course
Browse files Browse the repository at this point in the history
  • Loading branch information
shadinaif committed Jun 13, 2024
1 parent 0d45e03 commit 60114b7
Show file tree
Hide file tree
Showing 20 changed files with 498 additions and 63 deletions.
116 changes: 103 additions & 13 deletions futurex_openedx_extensions/dashboard/details/learners.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
"""Learners details collectors"""
from __future__ import annotations

from datetime import timedelta
from typing import List

from common.djangoapps.student.models import CourseAccessRole
from django.contrib.auth import get_user_model
from django.db.models import Count, Exists, OuterRef, Q, Subquery
from django.db.models import BooleanField, Case, Count, Exists, OuterRef, Q, Subquery, Value, When
from django.db.models.query import QuerySet
from django.utils import timezone
from lms.djangoapps.certificates.models import GeneratedCertificate
from lms.djangoapps.courseware.models import StudentModule
from lms.djangoapps.grades.models import PersistentCourseGrade

from futurex_openedx_extensions.helpers.querysets import get_base_queryset_courses, get_has_site_login_queryset
from futurex_openedx_extensions.helpers.tenants import get_course_org_filter_list, get_tenants_sites
Expand Down Expand Up @@ -81,6 +86,46 @@ def get_certificates_count_for_learner_queryset(
)


def get_learners_search_queryset(
search_text: str = None,
superuser_filter: bool | None = False,
staff_filter: bool | None = False,
active_filter: bool | None = True
) -> QuerySet:
"""
Get the learners queryset for the given search text.
:param search_text: Search text to filter the learners by
:type search_text: str
:param superuser_filter: Value to filter superusers. None means no filter
:type superuser_filter: bool
:param staff_filter: Value to filter staff users. None means no filter
:type staff_filter: bool
:param active_filter: Value to filter active users. None means no filter
:type active_filter: bool
:return: QuerySet of learners
:rtype: QuerySet
"""
queryset = get_user_model().objects.all()

if superuser_filter is not None:
queryset = queryset.filter(is_superuser=superuser_filter)
if staff_filter is not None:
queryset = queryset.filter(is_staff=staff_filter)
if active_filter is not None:
queryset = queryset.filter(is_active=active_filter)

search_text = (search_text or '').strip()
if search_text:
queryset = queryset.filter(
Q(username__icontains=search_text) |
Q(email__icontains=search_text) |
Q(profile__name__icontains=search_text)
)

return queryset


def get_learners_queryset(
tenant_ids: List, search_text: str = None, visible_courses_filter: bool = True, active_courses_filter: bool = None
) -> QuerySet:
Expand All @@ -101,18 +146,7 @@ def get_learners_queryset(
course_org_filter_list = get_course_org_filter_list(tenant_ids)['course_org_filter_list']
tenant_sites = get_tenants_sites(tenant_ids)

queryset = get_user_model().objects.filter(
is_superuser=False,
is_staff=False,
is_active=True,
)
search_text = (search_text or '').strip()
if search_text:
queryset = queryset.filter(
Q(username__icontains=search_text) |
Q(email__icontains=search_text) |
Q(profile__name__icontains=search_text)
)
queryset = get_learners_search_queryset(search_text)

queryset = queryset.annotate(
courses_count=get_courses_count_for_learner_queryset(
Expand All @@ -135,6 +169,62 @@ def get_learners_queryset(
return queryset


def get_learners_by_course_queryset(course_id: str, search_text: str = None) -> QuerySet:
"""
Get the learners queryset for the given course ID.
:param course_id: The course ID to get the learners for
:type course_id: str
:param search_text: Search text to filter the learners by
:type search_text: str
:return: QuerySet of learners
:rtype: QuerySet
"""
queryset = get_learners_search_queryset(search_text)
queryset = queryset.filter(
courseenrollment__course_id=course_id
).filter(
~Exists(
CourseAccessRole.objects.filter(
user_id=OuterRef('id'),
org=OuterRef('courseenrollment__course__org')
)
)
).annotate(
certificate_available=Exists(
GeneratedCertificate.objects.filter(
user_id=OuterRef('id'),
course_id=course_id,
status='downloadable'
)
)
).annotate(
course_score=Subquery(
PersistentCourseGrade.objects.filter(
user_id=OuterRef('id'),
course_id=course_id
).values('percent_grade')[:1]
)
).annotate(
active_in_course=Case(
When(
Exists(
StudentModule.objects.filter(
student_id=OuterRef('id'),
course_id=course_id,
modified__gte=timezone.now() - timedelta(days=30)
)
),
then=Value(True),
),
default=Value(False),
output_field=BooleanField(),
)
).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:
Expand Down
42 changes: 33 additions & 9 deletions futurex_openedx_extensions/dashboard/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
from futurex_openedx_extensions.helpers.tenants import get_tenants_by_org


class LearnerDetailsSerializer(serializers.ModelSerializer):
"""Serializer for learner details."""
class LearnerBasicDetailsSerializer(serializers.ModelSerializer):
"""Serializer for learner's basic details."""
user_id = serializers.SerializerMethodField()
full_name = serializers.SerializerMethodField()
alternative_full_name = serializers.SerializerMethodField()
Expand All @@ -27,8 +27,6 @@ class LearnerDetailsSerializer(serializers.ModelSerializer):
gender_display = serializers.SerializerMethodField()
date_joined = serializers.DateTimeField()
last_login = serializers.DateTimeField()
enrolled_courses_count = serializers.SerializerMethodField()
certificates_count = serializers.SerializerMethodField()

class Meta:
model = get_user_model()
Expand All @@ -44,8 +42,6 @@ class Meta:
"gender_display",
"date_joined",
"last_login",
"enrolled_courses_count",
"certificates_count",
]

@staticmethod
Expand Down Expand Up @@ -110,6 +106,23 @@ def get_gender_display(self, obj):
"""Return readable text for gender"""
return self._get_profile_field(obj, "gender_display")

def get_year_of_birth(self, obj):
"""Return year of birth."""
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): # pylint: disable=no-self-use
"""Return certificates count."""
return obj.certificates_count
Expand All @@ -118,9 +131,20 @@ def get_enrolled_courses_count(self, obj): # pylint: disable=no-self-use
"""Return enrolled courses count."""
return obj.courses_count

def get_year_of_birth(self, obj):
"""Return year of birth."""
return self._get_profile_field(obj, "year_of_birth")

class LearnerDetailsForCourseSerializer(LearnerBasicDetailsSerializer):
"""Serializer for learner details for a course."""
certificate_available = serializers.BooleanField()
course_score = serializers.DecimalField(max_digits=5, decimal_places=2)
active_in_course = serializers.BooleanField()

class Meta:
model = get_user_model()
fields = LearnerBasicDetailsSerializer.Meta.fields + [
"certificate_available",
"course_score",
"active_in_course",
]


class LearnerDetailsExtendedSerializer(LearnerDetailsSerializer):
Expand Down
4 changes: 4 additions & 0 deletions futurex_openedx_extensions/dashboard/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@
from django.urls import re_path

from futurex_openedx_extensions.dashboard import views
from futurex_openedx_extensions.helpers.constants import COURSE_ID_REGX

app_name = 'fx_dashboard'

urlpatterns = [
re_path(r'^api/fx/accessible/v1/info/$', views.AccessibleTenantsInfoView.as_view(), name='accessible-info'),
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(
fr'^api/fx/learners/v1/learners/{COURSE_ID_REGX}/$',
views.LearnersDetailsForCourseView.as_view(), name='learners-course'),
re_path(
r'^api/fx/learners/v1/learner/' + settings.USERNAME_PATTERN + '/$',
views.LearnerInfoView.as_view(),
Expand Down
25 changes: 23 additions & 2 deletions futurex_openedx_extensions/dashboard/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@

from futurex_openedx_extensions.dashboard import serializers
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.details.learners import (
get_learner_info_queryset,
get_learners_by_course_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
from futurex_openedx_extensions.helpers.constants import COURSE_STATUS_SELF_PREFIX, COURSE_STATUSES
from futurex_openedx_extensions.helpers.converters import error_details_to_dictionary
from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter
from futurex_openedx_extensions.helpers.pagination import DefaultPagination
from futurex_openedx_extensions.helpers.permissions import HasTenantAccess, IsSystemStaff
from futurex_openedx_extensions.helpers.permissions import HasCourseAccess, HasTenantAccess, IsSystemStaff
from futurex_openedx_extensions.helpers.tenants import (
get_accessible_tenant_ids,
get_selected_tenants,
Expand Down Expand Up @@ -253,3 +257,20 @@ def get(self, request, *args, **kwargs): # pylint: disable=no-self-use

tenant_ids = get_accessible_tenant_ids(user)
return JsonResponse(get_tenants_info(tenant_ids))


class LearnersDetailsForCourseView(ListAPIView):
"""View to get the list of learners for a course"""
serializer_class = serializers.LearnerDetailsForCourseSerializer
permission_classes = [HasCourseAccess]
pagination_class = DefaultPagination

def get_queryset(self, *args, **kwargs):
"""Get the list of learners for a course"""
search_text = self.request.query_params.get('search_text')
course_id = self.kwargs.get('course_id')

return get_learners_by_course_queryset(
course_id=course_id,
search_text=search_text,
)
3 changes: 3 additions & 0 deletions futurex_openedx_extensions/helpers/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
CACHE_NAME_ALL_TENANTS_INFO = "all_tenants_info_v2"
CACHE_NAME_ALL_COURSE_ORG_FILTER_LIST = "all_course_org_filter_list_v2"

COURSE_ID_REGX = r"(?P<course_id>course-v1:(?P<org>[a-zA-Z0-9_]+)\+(?P<course>[a-zA-Z0-9_]+)\+(?P<run>[a-zA-Z0-9_]+))"


COURSE_STATUSES = {
"active": "active",
"archived": "archived",
Expand Down
25 changes: 25 additions & 0 deletions futurex_openedx_extensions/helpers/extractors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Helper functions for FutureX Open edX Extensions."""
from __future__ import annotations

import re
from typing import List
from urllib.parse import urlparse

from futurex_openedx_extensions.helpers.constants import COURSE_ID_REGX


def get_course_id_from_uri(uri: str) -> str | None:
"""Extract the course_id from the URI."""
path_parts = urlparse(uri).path.split("/")

for part in path_parts:
result = re.search(r"^" + COURSE_ID_REGX, part)
if result:
return result.groupdict().get('course_id')

return None


def get_first_not_empty_item(items: List, default=None) -> any:
"""Return the first item in the list that is not empty."""
return next((item for item in items if item), default)
9 changes: 0 additions & 9 deletions futurex_openedx_extensions/helpers/helpers.py

This file was deleted.

31 changes: 31 additions & 0 deletions futurex_openedx_extensions/helpers/permissions.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
"""Permission classes for FutureX Open edX Extensions."""
import json

from common.djangoapps.student.models import CourseAccessRole
from django.db.models import Subquery
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from rest_framework.exceptions import NotAuthenticated, PermissionDenied
from rest_framework.permissions import IsAuthenticated

from futurex_openedx_extensions.helpers.constants import TENANT_LIMITED_ADMIN_ROLES
from futurex_openedx_extensions.helpers.extractors import get_course_id_from_uri
from futurex_openedx_extensions.helpers.tenants import check_tenant_access


class HasCourseAccess(IsAuthenticated):
"""Permission class to check if the user has access to the course."""
def has_permission(self, request, view):
"""Check if the user has access to the course."""
if not super().has_permission(request, view):
raise NotAuthenticated()

course_id = get_course_id_from_uri(request.build_absolute_uri())
if not course_id or not CourseOverview.objects.filter(id=course_id).exists():
raise PermissionDenied(detail=json.dumps({"reason": "Invalid course_id"}))

if request.user.is_staff or request.user.is_superuser:
return True

if not CourseAccessRole.objects.filter(
user=request.user,
org=Subquery(
CourseOverview.objects.filter(id=course_id).values('org')
),
role__in=TENANT_LIMITED_ADMIN_ROLES,
).exists():
raise PermissionDenied(detail=json.dumps({"reason": "User does not have access to the course"}))

return True


class HasTenantAccess(IsAuthenticated):
"""Permission class to check if the user is a tenant admin."""
def has_permission(self, request, view):
Expand Down
2 changes: 1 addition & 1 deletion futurex_openedx_extensions/helpers/tenants.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from futurex_openedx_extensions.helpers import constants as cs
from futurex_openedx_extensions.helpers.caching import cache_dict
from futurex_openedx_extensions.helpers.converters import error_details_to_dictionary, ids_string_to_list
from futurex_openedx_extensions.helpers.helpers import get_first_not_empty_item
from futurex_openedx_extensions.helpers.extractors import get_first_not_empty_item
from futurex_openedx_extensions.helpers.querysets import get_has_site_login_queryset


Expand Down
Loading

0 comments on commit 60114b7

Please sign in to comment.