Skip to content

Commit

Permalink
feat: Add new API for learner courses information
Browse files Browse the repository at this point in the history
  • Loading branch information
shadinaif committed May 27, 2024
1 parent 460c6c3 commit be58b87
Show file tree
Hide file tree
Showing 17 changed files with 561 additions and 76 deletions.
2 changes: 1 addition & 1 deletion futurex_openedx_extensions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""One-line description for README and other doc files."""

__version__ = '0.2.1'
__version__ = '0.3.2'
79 changes: 78 additions & 1 deletion futurex_openedx_extensions/dashboard/details/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
110 changes: 91 additions & 19 deletions futurex_openedx_extensions/dashboard/serializers.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -201,10 +199,6 @@ class Meta:
"id",
"status",
"self_paced",
"rating",
"enrolled_count",
"active_count",
"certificates_count",
"start_date",
"end_date",
"start_enrollment_date",
Expand All @@ -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
Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions futurex_openedx_extensions/dashboard/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]
21 changes: 20 additions & 1 deletion futurex_openedx_extensions/dashboard/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<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)

courses = get_learner_courses_info_queryset(tenant_ids, user_id)

return Response(serializers.LearnerCoursesDetailsSerializer(courses, many=True).data)
8 changes: 8 additions & 0 deletions futurex_openedx_extensions/helpers/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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
2 changes: 2 additions & 0 deletions test_utils/edx_platform_mocks/completion/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""completion Mocks"""
from fake_models.models import BlockCompletion # pylint: disable=unused-import
22 changes: 22 additions & 0 deletions test_utils/edx_platform_mocks/fake_models/functions.py
Original file line number Diff line number Diff line change
@@ -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 {}
26 changes: 26 additions & 0 deletions test_utils/edx_platform_mocks/fake_models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""edx-platform Mocks"""
from fake_models.functions import get_certificates_for_user_by_course_keys # pylint: disable=unused-import
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""edx-platform Mock"""
from fake_models.functions import get_course_blocks_completion_summary # pylint: disable=unused-import
2 changes: 2 additions & 0 deletions test_utils/edx_platform_mocks/lms/djangoapps/grades/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""edx-platform Mock"""
from fake_models.models import CourseGradeFactory # pylint: disable=unused-import
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""edx-platform Mocks"""
from fake_models.functions import get_block_structure_manager # pylint: disable=unused-import
Loading

0 comments on commit be58b87

Please sign in to comment.