Skip to content

Commit

Permalink
feat: course details API
Browse files Browse the repository at this point in the history
  • Loading branch information
shadinaif committed Apr 18, 2024
1 parent e4b1fa8 commit 736d7db
Show file tree
Hide file tree
Showing 9 changed files with 245 additions and 20 deletions.
64 changes: 64 additions & 0 deletions futurex_openedx_extensions/dashboard/details/courses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Courses details collectors"""
from __future__ import annotations

from typing import List

from common.djangoapps.student.models import CourseEnrollment
from django.db.models import Count, OuterRef, Q
from django.db.models.query import QuerySet
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=1
).annotate(
enrolled_count=Count(
CourseEnrollment.objects.filter(
course_id=OuterRef('id'),
is_active=True,
)
)
).annotate(
active_count=Count(
CourseEnrollment.objects.filter(
course_id=OuterRef('id'),
is_active=True,
)
)
).annotate(
certificates_count=Count(
GeneratedCertificate.objects.filter(
course_id=OuterRef('id'),
status='downloadable'
)
)
)

return queryset
78 changes: 77 additions & 1 deletion futurex_openedx_extensions/dashboard/serializers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""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.tenants import get_tenants_by_org


class LearnerDetailsSerializer(serializers.ModelSerializer):
"""Serializer for learner details."""
Expand Down Expand Up @@ -66,3 +69,76 @@ 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_ACTIVE = 'active'
STATUS_ARCHIVED = 'archived'
STATUS_SOON = 'soon'
STATUS_SELF_PREFIX = 'self_'

status = serializers.SerializerMethodField()
rating = serializers.SerializerMethodField()
# enrolled_count = serializers.SerializerMethodField()
# active_count = serializers.SerializerMethodField()
# certificates_count = serializers.SerializerMethodField()
# start_date = serializers.SerializerMethodField()
# end_date = serializers.SerializerMethodField()
start_enrollment_date = serializers.SerializerMethodField()
end_enrollment_date = serializers.SerializerMethodField()
# display_name = serializers.SerializerMethodField()
image_url = serializers.SerializerMethodField()
# org = serializers.SerializerMethodField()
tenant_ids = 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',
]

def get_status(self, obj):
"""Return the course status."""
if obj.end and obj.end < now():
status = self.STATUS_ARCHIVED
elif obj.start and obj.start > now():
status = self.STATUS_SOON
else:
status = self.STATUS_ACTIVE

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

def get_rating(self, obj): # pylint: disable=unused-argument
"""Return the course rating."""
return 3.5

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)
1 change: 1 addition & 0 deletions futurex_openedx_extensions/dashboard/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@
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', LearnersView.as_view(), name='courses'),
]
33 changes: 31 additions & 2 deletions futurex_openedx_extensions/dashboard/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
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.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.pagination import DefaultPagination
from futurex_openedx_extensions.helpers.pagination import DefaultOrderingFilter, DefaultPagination
from futurex_openedx_extensions.helpers.permissions import HasTenantAccess
from futurex_openedx_extensions.helpers.tenants import get_accessible_tenant_ids

Expand Down Expand Up @@ -109,3 +110,31 @@ def get_queryset(self):
tenant_ids=ids_string_to_list(tenant_ids) if tenant_ids else get_accessible_tenant_ids(self.request.user),
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 = self.request.query_params.get('tenant_ids')
search_text = self.request.query_params.get('search_text')
return get_courses_queryset(
tenant_ids=ids_string_to_list(tenant_ids) if tenant_ids else get_accessible_tenant_ids(self.request.user),
search_text=search_text,
)

# def filter_queryset(self, queryset):
# """Filter the queryset"""
# queryset = super().filter_queryset(queryset)
# return super().filter_queryset(queryset)
5 changes: 5 additions & 0 deletions futurex_openedx_extensions/helpers/pagination.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
"""Pagination helpers and classes for the API views."""
from rest_framework.filters import OrderingFilter
from rest_framework.pagination import PageNumberPagination


class DefaultPagination(PageNumberPagination):
page_size = 20
page_size_query_param = 'page_size'
max_page_size = 100


class DefaultOrderingFilter(OrderingFilter):
ordering_param = 'sort'
13 changes: 13 additions & 0 deletions futurex_openedx_extensions/helpers/tenants.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,16 @@ def check_tenant_access(user: get_user_model(), tenant_ids_string: str) -> tuple
)

return True, {}


def get_tenants_by_org(org: str) -> List[int]:
"""
Get the tenants that have <org> in their course org filter
:param org: The org to check
:type org: str
:return: List of tenant IDs
:rtype: List[int]
"""
tenant_configs = get_all_course_org_filter_list()
return [t_id for t_id, course_org_filter in tenant_configs.items() if org in course_org_filter]
19 changes: 19 additions & 0 deletions tests/test_dashboard/test_details/test_details_courses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Tests for courses details collectors"""
import pytest

from futurex_openedx_extensions.dashboard.details.learners import get_learners_queryset


@pytest.mark.django_db
@pytest.mark.parametrize('tenant_ids, search_text, expected_count', [
([7, 8], None, 22),
([7], None, 17),
([7], 'user', 17),
([7], 'user4', 10),
([7], 'user5', 1),
([7], 'user6', 0),
([4], None, 0),
])
def test_get_learners_queryset(base_data, tenant_ids, search_text, expected_count): # pylint: disable=unused-argument
"""Verify that get_learners_queryset returns the correct QuerySet."""
assert get_learners_queryset(tenant_ids, search_text).count() == expected_count
8 changes: 5 additions & 3 deletions tests/test_dashboard/test_statistics/test_courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@


@pytest.mark.django_db
def test_get_courses_count(base_data):
def test_get_courses_count(base_data): # pylint: disable=unused-argument
"""Verify get_courses_count function."""
all_tenants = _base_data["tenant_config"].keys()
result = courses.get_courses_count(all_tenants)
Expand Down Expand Up @@ -38,7 +38,9 @@ def test_get_courses_count(base_data):
(-1, 1, 5),
(-2, -1, 4),
])
def test_get_courses_count_only_active(base_data, start_diff, end_diff, expected_org1_count):
def test_get_courses_count_only_active(
base_data, start_diff, end_diff, expected_org1_count
): # pylint: disable=unused-argument
"""Verify get_courses_count function with only_active=True."""
course = CourseOverview.objects.filter(org="ORG1").first()
assert course.start is None
Expand All @@ -59,7 +61,7 @@ def test_get_courses_count_only_active(base_data, start_diff, end_diff, expected


@pytest.mark.django_db
def test_get_courses_count_only_visible(base_data):
def test_get_courses_count_only_visible(base_data): # pylint: disable=unused-argument
"""Verify get_courses_count function with only_visible=True."""
course = CourseOverview.objects.filter(org="ORG1").first()
assert course.visible_to_staff_only is False
Expand Down
Loading

0 comments on commit 736d7db

Please sign in to comment.