Skip to content

Commit

Permalink
Merge pull request #6 from nelc/shadinaif/courses-apis
Browse files Browse the repository at this point in the history
feat: course details and course statuses APIs
  • Loading branch information
shadinaif authored May 20, 2024
2 parents 6add8d9 + a5b1fdd commit 35846cc
Show file tree
Hide file tree
Showing 44 changed files with 1,015 additions and 293 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.1.1'
__version__ = '0.2.1'
107 changes: 107 additions & 0 deletions futurex_openedx_extensions/dashboard/details/courses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Courses details collectors"""
from __future__ import annotations

from typing import List

from common.djangoapps.student.models import CourseAccessRole
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery, Sum
from django.db.models.functions import Coalesce
from django.db.models.query import QuerySet
from eox_nelp.course_experience.models import FeedbackCourse
from lms.djangoapps.certificates.models import GeneratedCertificate

from futurex_openedx_extensions.helpers.querysets import get_base_queryset_courses
from futurex_openedx_extensions.helpers.tenants import get_course_org_filter_list, get_tenant_site


def get_courses_queryset(
tenant_ids: List, search_text: str = None, only_visible: bool = True, only_active: bool = False
) -> QuerySet:
"""
Get the courses queryset for the given tenant IDs and search text.
:param tenant_ids: List of tenant IDs to get the courses for
:type tenant_ids: List
:param search_text: Search text to filter the courses by
:type search_text: str
:param only_visible: Whether to only include courses that are visible in the catalog
:type only_visible: bool
:param only_active: Whether to only include active courses
:type only_active: bool
:return: QuerySet of courses
:rtype: QuerySet
"""
course_org_filter_list = get_course_org_filter_list(tenant_ids)['course_org_filter_list']
tenant_sites = []
for tenant_id in tenant_ids:
if site := get_tenant_site(tenant_id):
tenant_sites.append(site)

queryset = get_base_queryset_courses(course_org_filter_list, only_visible=only_visible, only_active=only_active)

search_text = (search_text or '').strip()
if search_text:
queryset = queryset.filter(
Q(display_name__icontains=search_text) |
Q(id__icontains=search_text),
)
queryset = queryset.annotate(
rating_count=Coalesce(Subquery(
FeedbackCourse.objects.filter(
course_id=OuterRef('id'),
rating_content__isnull=False,
rating_content__gt=0,
).values('course_id').annotate(count=Count('id')).values('count'),
output_field=IntegerField(),
), 0),
).annotate(
rating_total=Coalesce(Subquery(
FeedbackCourse.objects.filter(
course_id=OuterRef('id'),
rating_content__isnull=False,
rating_content__gt=0,
).values('course_id').annotate(total=Sum('rating_content')).values('total'),
), 0),
).annotate(
enrolled_count=Count(
'courseenrollment',
filter=(
Q(courseenrollment__is_active=True) &
Q(courseenrollment__user__is_active=True) &
Q(courseenrollment__user__is_staff=False) &
Q(courseenrollment__user__is_superuser=False) &
~Exists(
CourseAccessRole.objects.filter(
user_id=OuterRef('courseenrollment__user_id'),
org=OuterRef('org'),
),
)
),
)
).annotate(
active_count=Count(
'courseenrollment',
filter=(
Q(courseenrollment__is_active=True) &
Q(courseenrollment__user__is_active=True) &
Q(courseenrollment__user__is_staff=False) &
Q(courseenrollment__user__is_superuser=False) &
~Exists(
CourseAccessRole.objects.filter(
user_id=OuterRef('courseenrollment__user_id'),
org=OuterRef('org'),
),
)
),
)
).annotate(
certificates_count=Coalesce(Subquery(
GeneratedCertificate.objects.filter(
course_id=OuterRef('id'),
status='downloadable'
).values('course_id').annotate(count=Count('id')).values('count'),
output_field=IntegerField(),
), 0),
)

return queryset
24 changes: 19 additions & 5 deletions futurex_openedx_extensions/dashboard/details/learners.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,27 @@
from django.contrib.auth import get_user_model
from django.db.models import Count, Exists, OuterRef, Q, Subquery
from django.db.models.query import QuerySet
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview

from futurex_openedx_extensions.helpers.querysets import get_base_queryset_courses
from futurex_openedx_extensions.helpers.tenants import get_course_org_filter_list, get_tenant_site


def get_learners_queryset(tenant_ids: List, search_text: str = None) -> QuerySet:
def get_learners_queryset(
tenant_ids: List, search_text: str = None, only_visible_courses: bool = True, only_active_courses: bool = False
) -> QuerySet:
"""
Get the learners queryset for the given tenant IDs and search text.
:param tenant_ids: List of tenant IDs to get the learners for
:type tenant_ids: List
:param search_text: Search text to filter the learners by
:type search_text: str
:param only_visible_courses: Whether to only count courses that are visible in the catalog
:type only_visible_courses: bool
:param only_active_courses: Whether to only count active courses
:type only_active_courses: bool
:return: QuerySet of learners
:rtype: QuerySet
"""
course_org_filter_list = get_course_org_filter_list(tenant_ids)['course_org_filter_list']
tenant_sites = []
Expand All @@ -44,7 +52,11 @@ def get_learners_queryset(tenant_ids: List, search_text: str = None) -> QuerySet
courses_count=Count(
'courseenrollment',
filter=(
Q(courseenrollment__course__org__in=course_org_filter_list) &
Q(courseenrollment__course_id__in=get_base_queryset_courses(
course_org_filter_list,
only_visible=only_visible_courses,
only_active=only_active_courses,
)) &
~Exists(
CourseAccessRole.objects.filter(
user_id=OuterRef('id'),
Expand All @@ -59,8 +71,10 @@ def get_learners_queryset(tenant_ids: List, search_text: str = None) -> QuerySet
'generatedcertificate',
filter=(
Q(generatedcertificate__course_id__in=Subquery(
CourseOverview.objects.filter(
org__in=course_org_filter_list
get_base_queryset_courses(
course_org_filter_list,
only_visible=only_visible_courses,
only_active=only_active_courses
).values_list('id', flat=True)
)) &
Q(generatedcertificate__status='downloadable')
Expand Down
101 changes: 94 additions & 7 deletions futurex_openedx_extensions/dashboard/serializers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"""Serializers for the dashboard details API."""

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 rest_framework import serializers

from futurex_openedx_extensions.helpers.constants import COURSE_STATUS_SELF_PREFIX, COURSE_STATUSES
from futurex_openedx_extensions.helpers.tenants import get_tenants_by_org


class LearnerDetailsSerializer(serializers.ModelSerializer):
"""Serializer for learner details."""
Expand All @@ -11,7 +15,7 @@ class LearnerDetailsSerializer(serializers.ModelSerializer):
username = serializers.CharField()
email = serializers.EmailField()
mobile_no = serializers.SerializerMethodField()
date_of_birth = serializers.SerializerMethodField()
year_of_birth = serializers.SerializerMethodField()
gender = serializers.SerializerMethodField()
date_joined = serializers.DateTimeField()
last_login = serializers.DateTimeField()
Expand All @@ -26,7 +30,7 @@ class Meta:
'username',
'email',
'mobile_no',
'date_of_birth',
'year_of_birth',
'gender',
'date_joined',
'last_login',
Expand All @@ -51,10 +55,6 @@ def get_mobile_no(self, obj):
"""Return mobile number."""
return self._get_profile_field(obj, 'phone_number')

def get_date_of_birth(self, obj): # pylint: disable=unused-argument
"""Return date of birth."""
return None

def get_gender(self, obj):
"""Return gender."""
return self._get_profile_field(obj, 'gender')
Expand All @@ -66,3 +66,90 @@ def get_certificates_count(self, obj):
def get_enrolled_courses_count(self, obj):
"""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 CourseDetailsSerializer(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()
end_enrollment_date = serializers.SerializerMethodField()
display_name = serializers.CharField()
image_url = serializers.SerializerMethodField()
org = serializers.CharField()
tenant_ids = serializers.SerializerMethodField()
author_name = serializers.SerializerMethodField()

class Meta:
model = CourseOverview
fields = [
'id',
'status',
'self_paced',
'rating',
'enrolled_count',
'active_count',
'certificates_count',
'start_date',
'end_date',
'start_enrollment_date',
'end_enrollment_date',
'display_name',
'image_url',
'org',
'tenant_ids',
'author_name',
]

def get_status(self, obj):
"""Return the course status."""
now_time = now()
if obj.end and obj.end < now_time:
status = COURSE_STATUSES['archived']
elif obj.start and obj.start > now_time:
status = COURSE_STATUSES['upcoming']
else:
status = COURSE_STATUSES['active']

return f'{COURSE_STATUS_SELF_PREFIX if obj.self_paced else ""}{status}'

def get_rating(self, obj):
"""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):
"""Return the start enrollment date."""
return obj.enrollment_start

def get_end_enrollment_date(self, obj):
"""Return the end enrollment date."""
return obj.enrollment_end

def get_image_url(self, obj):
"""Return the course image URL."""
return obj.course_image_url

def get_tenant_ids(self, obj):
"""Return the tenant IDs."""
return get_tenants_by_org(obj.org)

def get_start_date(self, obj):
"""Return the start date."""
return obj.start

def get_end_date(self, obj):
"""Return the end date."""
return obj.end

def get_author_name(self, obj): # pylint: disable=unused-argument
"""Return the author name."""
return None
15 changes: 12 additions & 3 deletions futurex_openedx_extensions/dashboard/statistics/certificates.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,34 @@
from lms.djangoapps.certificates.models import GeneratedCertificate
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview

from futurex_openedx_extensions.helpers.querysets import get_base_queryset_courses
from futurex_openedx_extensions.helpers.tenants import get_course_org_filter_list


def get_certificates_count(tenant_ids: List[int]) -> Dict[str, int]:
def get_certificates_count(
tenant_ids: List[int], only_visible_courses: bool = True, only_active_courses: bool = False
) -> Dict[str, int]:
"""
Get the count of issued certificates in the given tenants. The count is grouped by organization. Certificates
for admins, staff, and superusers are also included.
:param tenant_ids: List of tenant IDs to get the count for
:type tenant_ids: List[int]
:param only_visible_courses: Whether to only count courses that are visible in the catalog
:type only_visible_courses: bool
:param only_active_courses: Whether to only count active courses (according to dates)
:type only_active_courses: bool
:return: Count of certificates per organization
:rtype: Dict[str, int]
"""
course_org_filter_list = get_course_org_filter_list(tenant_ids)['course_org_filter_list']

result = list(GeneratedCertificate.objects.filter(
status='downloadable',
course_id__in=CourseOverview.objects.filter(
org__in=course_org_filter_list
course_id__in=get_base_queryset_courses(
course_org_filter_list,
only_visible=only_visible_courses,
only_active=only_active_courses,
),
).annotate(course_org=Subquery(
CourseOverview.objects.filter(
Expand Down
Loading

0 comments on commit 35846cc

Please sign in to comment.