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 19, 2024
1 parent 8adc75b commit 6e63620
Show file tree
Hide file tree
Showing 19 changed files with 411 additions and 38 deletions.
77 changes: 77 additions & 0 deletions futurex_openedx_extensions/dashboard/details/courses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""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, Subquery, Sum
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=Count(Subquery(
FeedbackCourse.objects.filter(
course_id=OuterRef('id'),
rating_content__gt=0,
).values('id')
))
).annotate(
rating_total=Sum(
FeedbackCourse.objects.filter(
course_id=OuterRef('id'),
rating_content__gt=0,
).values_list('rating_content', flat=True)
)
).annotate(
enrolled_count=Count(Subquery(
CourseEnrollment.objects.filter(
course_id=OuterRef('id'),
is_active=True,
).values('id')
))
).annotate(
active_count=Count(Subquery(
CourseEnrollment.objects.filter(
course_id=OuterRef('id'),
is_active=True,
).values('id')
))
).annotate(
certificates_count=Count(Subquery(
GeneratedCertificate.objects.filter(
course_id=OuterRef('id'),
status='downloadable'
).values('id')
))
)

return queryset
92 changes: 91 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,90 @@ 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.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 = 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):
"""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
3 changes: 2 additions & 1 deletion futurex_openedx_extensions/dashboard/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
"""
from django.urls import re_path

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

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', CoursesView.as_view(), name='courses'),
]
27 changes: 26 additions & 1 deletion futurex_openedx_extensions/dashboard/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +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.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
Expand Down Expand Up @@ -109,3 +111,26 @@ 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,
)
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'
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]
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""edx-platform Mocks"""
from fake_models.models import FeedbackCourse # pylint: disable=unused-import
36 changes: 36 additions & 0 deletions test_utils/edx_platform_mocks/fake_models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ class CourseOverview(models.Model):
visible_to_staff_only = models.BooleanField()
start = models.DateTimeField(null=True)
end = models.DateTimeField(null=True)
display_name = models.TextField(null=True)
enrollment_start = models.DateTimeField(null=True)
enrollment_end = models.DateTimeField(null=True)
self_paced = models.BooleanField(default=False)
course_image_url = models.TextField()

class Meta:
app_label = "fake_models"
Expand Down Expand Up @@ -89,3 +94,34 @@ def has_profile_image(self):
class Meta:
app_label = "fake_models"
db_table = "auth_userprofile"


class BaseFeedback(models.Model):
"""Mock"""
RATING_OPTIONS = [
(0, '0'),
(1, '1'),
(2, '2'),
(3, '3'),
(4, '4'),
(5, '5')
]
author = models.ForeignKey(get_user_model(), null=True, on_delete=models.SET_NULL)
rating_content = models.IntegerField(blank=True, null=True, choices=RATING_OPTIONS)
feedback = models.CharField(max_length=500, blank=True, null=True)
public = models.BooleanField(null=True, default=False)
course_id = models.ForeignKey(CourseOverview, null=True, on_delete=models.SET_NULL)

class Meta:
"""Set model abstract"""
abstract = True


class FeedbackCourse(BaseFeedback):
"""Mock"""
rating_instructors = models.IntegerField(blank=True, null=True, choices=BaseFeedback.RATING_OPTIONS)
recommended = models.BooleanField(null=True, default=True)

class Meta:
"""Set constrain for author an course id"""
unique_together = [["author", "course_id"]]
2 changes: 1 addition & 1 deletion test_utils/edx_platform_mocks/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
setup(
name='edx_platform_mocks',
version='0.1.0',
packages=['common', 'fake_models', 'lms', 'openedx'],
packages=[],
)
8 changes: 5 additions & 3 deletions test_utils/eox_settings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""eox_tenant test settings."""
GET_SITE_CONFIGURATION_MODULE = 'eox_tenant.edxapp_wrapper.backends.site_configuration_module_i_v1'
"""EOX test settings."""

# eox-tenant settings
EOX_TENANT_USERS_BACKEND = 'eox_tenant.edxapp_wrapper.backends.users_l_v1'
GET_BRANDING_API = 'eox_tenant.edxapp_wrapper.backends.branding_api_l_v1'
GET_SITE_CONFIGURATION_MODULE = 'eox_tenant.edxapp_wrapper.backends.site_configuration_module_i_v1'
GET_THEMING_HELPERS = 'eox_tenant.edxapp_wrapper.backends.theming_helpers_h_v1'
EOX_TENANT_USERS_BACKEND = 'eox_tenant.edxapp_wrapper.backends.users_l_v1'
Empty file removed tests/__init__.py
Empty file.
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def _create_course_overviews():
id=f"course-v1:{org}+{i}+{i}",
org=org,
visible_to_staff_only=False,
display_name=f"Course {i} of {org}",
)

def _create_course_enrollments():
Expand Down
20 changes: 20 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,20 @@
"""Tests for courses details collectors"""
import pytest

from futurex_openedx_extensions.dashboard.details.courses import get_courses_queryset


@pytest.mark.django_db
@pytest.mark.parametrize('tenant_ids, search_text, expected_count', [
([7, 8], None, 5),
([7], None, 3),
([8], None, 2),
([7], 'Course 1', 1),
([7], 'Course 3', 1),
([7], 'course 3', 1),
([7], 'course 4', 0),
([4], None, 0),
])
def test_get_courses_queryset(base_data, tenant_ids, search_text, expected_count): # pylint: disable=unused-argument
"""Verify that get_courses_queryset returns the correct QuerySet."""
assert get_courses_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 6e63620

Please sign in to comment.