Skip to content

Commit

Permalink
feat: course details and course statuses APIs
Browse files Browse the repository at this point in the history
  • Loading branch information
shadinaif committed Apr 21, 2024
1 parent 8adc75b commit 365cbb0
Show file tree
Hide file tree
Showing 22 changed files with 681 additions and 73 deletions.
100 changes: 100 additions & 0 deletions futurex_openedx_extensions/dashboard/details/courses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""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 openedx.core.djangoapps.content.course_overviews.models import CourseOverview

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) -> 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
"""
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 = CourseOverview.objects.filter(
org__in=course_org_filter_list,
)
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
88 changes: 87 additions & 1 deletion 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 Down Expand Up @@ -66,3 +70,85 @@ def get_certificates_count(self, obj):
def get_enrolled_courses_count(self, obj):
"""Return enrolled courses count."""
return obj.courses_count


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."""
if obj.end and obj.end < now():
status = COURSE_STATUSES['archived']
elif obj.start and obj.start > now():
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
36 changes: 35 additions & 1 deletion futurex_openedx_extensions/dashboard/statistics/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@

from typing import List

from django.db.models import Count, Q
from django.db.models import Case, CharField, Count, Q, Value, When
from django.db.models.query import QuerySet
from django.utils.timezone import now
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview

from futurex_openedx_extensions.helpers.constants import COURSE_STATUSES
from futurex_openedx_extensions.helpers.tenants import get_course_org_filter_list


Expand Down Expand Up @@ -39,3 +40,36 @@ def get_courses_count(tenant_ids: List[int], only_active=False, only_visible=Fal
return q_set.values('org').annotate(
courses_count=Count('id')
).order_by('org')


def get_courses_count_by_status(tenant_ids: List[int]) -> QuerySet:
"""
Get the count of courses in the given tenants by status
:param tenant_ids: List of tenant IDs to get the count for
:type tenant_ids: List[int]
:return: QuerySet of courses count per organization and status
:rtype: QuerySet
"""
course_org_filter_list = get_course_org_filter_list(tenant_ids)['course_org_filter_list']

q_set = CourseOverview.objects.filter(
org__in=course_org_filter_list
).annotate(
status=Case(
When(
Q(end__isnull=False) & Q(end__lt=now()),
then=Value(COURSE_STATUSES['archived'])
),
When(
Q(start__isnull=False) & Q(start__gt=now()),
then=Value(COURSE_STATUSES['upcoming'])
),
default=Value(COURSE_STATUSES['active']),
output_field=CharField()
)
).values('status', 'self_paced').annotate(
courses_count=Count('id')
).values('status', 'self_paced', 'courses_count')

return q_set
8 changes: 5 additions & 3 deletions futurex_openedx_extensions/dashboard/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
"""
from django.urls import re_path

from futurex_openedx_extensions.dashboard.views import LearnersView, TotalCountsView
from futurex_openedx_extensions.dashboard import views

app_name = 'fx_dashboard'

urlpatterns = [
re_path(r'^api/fx/statistics/v1/total_counts', TotalCountsView.as_view(), name='total-counts'),
re_path(r'^api/fx/learners/v1/learners', LearnersView.as_view(), name='learners'),
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_ratings', views.CourseRatingsView.as_view(), name='course-ratings'),
re_path(r'^api/fx/statistics/v1/total_counts', views.TotalCountsView.as_view(), name='total-counts'),
]
76 changes: 65 additions & 11 deletions futurex_openedx_extensions/dashboard/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@
from rest_framework.response import Response
from rest_framework.views import APIView

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 LearnerDetailsSerializer
from futurex_openedx_extensions.dashboard.serializers import CourseDetailsSerializer, LearnerDetailsSerializer
from futurex_openedx_extensions.dashboard.statistics.certificates import get_certificates_count
from futurex_openedx_extensions.dashboard.statistics.courses import get_courses_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.converters import error_details_to_dictionary, ids_string_to_list
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
from futurex_openedx_extensions.helpers.tenants import get_accessible_tenant_ids
from futurex_openedx_extensions.helpers.tenants import get_selected_tenants


class TotalCountsView(APIView):
Expand Down Expand Up @@ -76,11 +79,7 @@ def get(self, request, *args, **kwargs):
if invalid_stats:
return Response(error_details_to_dictionary(reason="Invalid stats type", invalid=invalid_stats), status=400)

tenant_ids = request.query_params.get('tenant_ids')
if tenant_ids is None:
tenant_ids = get_accessible_tenant_ids(request.user)
else:
tenant_ids = ids_string_to_list(tenant_ids)
tenant_ids = get_selected_tenants(request)

result = dict({tenant_id: {} for tenant_id in tenant_ids})
result.update({
Expand All @@ -103,9 +102,64 @@ class LearnersView(ListAPIView):

def get_queryset(self):
"""Get the list of learners"""
tenant_ids = self.request.query_params.get('tenant_ids')
tenant_ids = get_selected_tenants(self.request)
search_text = self.request.query_params.get('search_text')
return get_learners_queryset(
tenant_ids=ids_string_to_list(tenant_ids) if tenant_ids else get_accessible_tenant_ids(self.request.user),
tenant_ids=tenant_ids,
search_text=search_text,
)


class CoursesView(ListAPIView):
"""View to get the list of courses"""
serializer_class = CourseDetailsSerializer
permission_classes = [HasTenantAccess]
pagination_class = DefaultPagination
filter_backends = [DefaultOrderingFilter]
ordering_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',
]
ordering = ['display_name']

def get_queryset(self):
"""Get the list of learners"""
tenant_ids = get_selected_tenants(self.request)
search_text = self.request.query_params.get('search_text')
return get_courses_queryset(
tenant_ids=tenant_ids,
search_text=search_text,
)


class CourseRatingsView(APIView):
"""View to get the course ratings"""
permission_classes = [HasTenantAccess]

@staticmethod
def to_json(result):
"""Convert the result to JSON format"""
dict_result = {
f"{COURSE_STATUS_SELF_PREFIX if self_paced else ''}{status}": 0
for status in COURSE_STATUSES
for self_paced in [False, True]
}

for item in result:
status = f"{COURSE_STATUS_SELF_PREFIX if item['self_paced'] else ''}{item['status']}"
dict_result[status] = item['courses_count']
return dict_result

def get(self, request, *args, **kwargs):
"""
GET /api/fx/statistics/v1/course_ratings/?tenant_ids=<tenantIds>
<tenantIds> (optional): a comma-separated list of the tenant IDs to get the information for. If not provided,
the API will assume the list of all accessible tenants by the user
"""
tenant_ids = get_selected_tenants(request)

result = get_courses_count_by_status(tenant_ids=tenant_ids)

return JsonResponse(self.to_json(result))
8 changes: 8 additions & 0 deletions futurex_openedx_extensions/helpers/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Constants for the FutureX Open edX Extensions app."""
COURSE_STATUSES = {
'active': 'active',
'archived': 'archived',
'upcoming': 'upcoming',
}

COURSE_STATUS_SELF_PREFIX = 'self_'
6 changes: 6 additions & 0 deletions futurex_openedx_extensions/helpers/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Filters helpers and classes for the API views."""
from rest_framework.filters import OrderingFilter


class DefaultOrderingFilter(OrderingFilter):
ordering_param = 'sort'
Loading

0 comments on commit 365cbb0

Please sign in to comment.