diff --git a/futurex_openedx_extensions/dashboard/details/courses.py b/futurex_openedx_extensions/dashboard/details/courses.py new file mode 100644 index 00000000..9e6016d8 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/details/courses.py @@ -0,0 +1,64 @@ +"""Courses details collectors""" +from __future__ import annotations + +from typing import List + +from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment, UserSignupSource +from django.db.models import Count, Exists, OuterRef, Q, Subquery +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 diff --git a/futurex_openedx_extensions/dashboard/serializers.py b/futurex_openedx_extensions/dashboard/serializers.py index 99a4d1f5..6cd2babb 100644 --- a/futurex_openedx_extensions/dashboard/serializers.py +++ b/futurex_openedx_extensions/dashboard/serializers.py @@ -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 helpers.tenants import get_tenants_by_org + class LearnerDetailsSerializer(serializers.ModelSerializer): """Serializer for learner details.""" @@ -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): + """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) diff --git a/futurex_openedx_extensions/dashboard/urls.py b/futurex_openedx_extensions/dashboard/urls.py index 1bb15397..e2e0cb8f 100644 --- a/futurex_openedx_extensions/dashboard/urls.py +++ b/futurex_openedx_extensions/dashboard/urls.py @@ -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'), ] diff --git a/futurex_openedx_extensions/dashboard/views.py b/futurex_openedx_extensions/dashboard/views.py index 05c2d74f..5e27a8a4 100644 --- a/futurex_openedx_extensions/dashboard/views.py +++ b/futurex_openedx_extensions/dashboard/views.py @@ -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 @@ -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) diff --git a/futurex_openedx_extensions/helpers/pagination.py b/futurex_openedx_extensions/helpers/pagination.py index 0b9dcd8e..bb43ecfd 100644 --- a/futurex_openedx_extensions/helpers/pagination.py +++ b/futurex_openedx_extensions/helpers/pagination.py @@ -1,4 +1,5 @@ """Pagination helpers and classes for the API views.""" +from rest_framework.filters import OrderingFilter from rest_framework.pagination import PageNumberPagination @@ -6,3 +7,7 @@ class DefaultPagination(PageNumberPagination): page_size = 20 page_size_query_param = 'page_size' max_page_size = 100 + + +class DefaultOrderingFilter(OrderingFilter): + ordering_param = 'sort' diff --git a/futurex_openedx_extensions/helpers/tenants.py b/futurex_openedx_extensions/helpers/tenants.py index 0989d93f..5d365ec3 100644 --- a/futurex_openedx_extensions/helpers/tenants.py +++ b/futurex_openedx_extensions/helpers/tenants.py @@ -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 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]