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 20, 2024
1 parent 8adc75b commit 63fe821
Show file tree
Hide file tree
Showing 19 changed files with 480 additions and 38 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
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
37 changes: 37 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,35 @@ 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"]]
db_table = "eox_nelp_feedbackcourse"
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
Loading

0 comments on commit 63fe821

Please sign in to comment.