diff --git a/futurex_openedx_extensions/dashboard/details/courses.py b/futurex_openedx_extensions/dashboard/details/courses.py new file mode 100644 index 00000000..8e988073 --- /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 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 diff --git a/futurex_openedx_extensions/dashboard/serializers.py b/futurex_openedx_extensions/dashboard/serializers.py index 99a4d1f5..47aa229c 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 futurex_openedx_extensions.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): # 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) 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] diff --git a/tests/test_dashboard/test_details/test_details_courses.py b/tests/test_dashboard/test_details/test_details_courses.py new file mode 100644 index 00000000..55f80909 --- /dev/null +++ b/tests/test_dashboard/test_details/test_details_courses.py @@ -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 diff --git a/tests/test_dashboard/test_statistics/test_courses.py b/tests/test_dashboard/test_statistics/test_courses.py index 30aa9def..e6f5bc4e 100644 --- a/tests/test_dashboard/test_statistics/test_courses.py +++ b/tests/test_dashboard/test_statistics/test_courses.py @@ -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) @@ -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 @@ -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 diff --git a/tests/test_helpers/test_tenants.py b/tests/test_helpers/test_tenants.py index 95f43b03..2d0bf374 100644 --- a/tests/test_helpers/test_tenants.py +++ b/tests/test_helpers/test_tenants.py @@ -10,14 +10,14 @@ @pytest.mark.django_db -def test_get_excluded_tenant_ids(base_data): +def test_get_excluded_tenant_ids(base_data): # pylint: disable=unused-argument """Verify get_excluded_tenant_ids function.""" result = tenants.get_excluded_tenant_ids() assert result == [4, 5, 6] @pytest.mark.django_db -def test_get_all_tenants(base_data): +def test_get_all_tenants(base_data): # pylint: disable=unused-argument """Verify get_all_tenants function.""" result = tenants.get_all_tenants() assert TenantConfig.objects.count() == 8 @@ -27,14 +27,14 @@ def test_get_all_tenants(base_data): @pytest.mark.django_db -def test_get_all_tenant_ids(base_data): +def test_get_all_tenant_ids(base_data): # pylint: disable=unused-argument """Verify get_all_tenant_ids function.""" result = tenants.get_all_tenant_ids() assert result == [1, 2, 3, 7, 8] @pytest.mark.django_db -def test_get_accessible_tenant_ids_none(base_data): +def test_get_accessible_tenant_ids_none(base_data): # pylint: disable=unused-argument """Verify that get_accessible_tenant_ids returns an empty list when user is None.""" result = tenants.get_accessible_tenant_ids(None) assert result == [] @@ -44,7 +44,7 @@ def test_get_accessible_tenant_ids_none(base_data): @pytest.mark.parametrize("user_id, expected", [ (1, [1, 2, 3, 7, 8]), ]) -def test_get_accessible_tenant_ids_super_users(base_data, user_id, expected): +def test_get_accessible_tenant_ids_super_users(base_data, user_id, expected): # pylint: disable=unused-argument """Verify get_accessible_tenant_ids function for super users.""" user = get_user_model().objects.get(id=user_id) assert user.is_superuser, 'only super users allowed in this test' @@ -56,7 +56,7 @@ def test_get_accessible_tenant_ids_super_users(base_data, user_id, expected): @pytest.mark.parametrize("user_id, expected", [ (2, [1, 2, 3, 7, 8]), ]) -def test_get_accessible_tenant_ids_staff(base_data, user_id, expected): +def test_get_accessible_tenant_ids_staff(base_data, user_id, expected): # pylint: disable=unused-argument """Verify get_accessible_tenant_ids function for staff users.""" user = get_user_model().objects.get(id=user_id) assert user.is_staff, 'only staff users allowed in this test' @@ -71,7 +71,9 @@ def test_get_accessible_tenant_ids_staff(base_data, user_id, expected): (9, [1]), (23, [2, 3, 8]), ]) -def test_get_accessible_tenant_ids_no_staff_no_sueperuser(base_data, user_id, expected): +def test_get_accessible_tenant_ids_no_staff_no_sueperuser( + base_data, user_id, expected +): # pylint: disable=unused-argument """Verify get_accessible_tenant_ids function for users with no staff and no superuser.""" user = get_user_model().objects.get(id=user_id) assert not user.is_staff and not user.is_superuser, 'only users with no staff and no superuser allowed in this test' @@ -80,7 +82,7 @@ def test_get_accessible_tenant_ids_no_staff_no_sueperuser(base_data, user_id, ex @pytest.mark.django_db -def test_get_accessible_tenant_ids_complex(base_data): +def test_get_accessible_tenant_ids_complex(base_data): # pylint: disable=unused-argument """Verify get_accessible_tenant_ids function for complex cases""" user = get_user_model().objects.get(id=10) user_access_role = 'org_course_creator_group' @@ -143,7 +145,7 @@ def test_get_accessible_tenant_ids_complex(base_data): } )), ]) -def test_check_tenant_access(base_data, user_id, ids_to_check, expected): +def test_check_tenant_access(base_data, user_id, ids_to_check, expected): # pylint: disable=unused-argument """Verify check_tenant_access function.""" user = get_user_model().objects.get(id=user_id) result = tenants.check_tenant_access(user, ids_to_check) @@ -151,7 +153,7 @@ def test_check_tenant_access(base_data, user_id, ids_to_check, expected): @pytest.mark.django_db -def test_get_all_course_org_filter_list(base_data): +def test_get_all_course_org_filter_list(base_data): # pylint: disable=unused-argument """Verify get_all_course_org_filter_list function.""" result = tenants.get_all_course_org_filter_list() assert result == { @@ -198,7 +200,7 @@ def test_get_all_course_org_filter_list(base_data): 'invalid': [], }), ]) -def test_get_course_org_filter_list(base_data, tenant_ids, expected): +def test_get_course_org_filter_list(base_data, tenant_ids, expected): # pylint: disable=unused-argument """Verify get_course_org_filter_list function.""" result = tenants.get_course_org_filter_list(tenant_ids) assert result == expected @@ -210,7 +212,7 @@ def test_get_course_org_filter_list(base_data, tenant_ids, expected): (2, [1, 2, 3, 7, 8]), (3, []), ]) -def test_get_accessible_tenant_ids(base_data, user_id, expected): +def test_get_accessible_tenant_ids(base_data, user_id, expected): # pylint: disable=unused-argument """Verify get_accessible_tenant_ids function.""" user = get_user_model().objects.get(id=user_id) result = tenants.get_accessible_tenant_ids(user) @@ -218,7 +220,7 @@ def test_get_accessible_tenant_ids(base_data, user_id, expected): @pytest.mark.django_db -def test_get_all_tenants_info(base_data): +def test_get_all_tenants_info(base_data): # pylint: disable=unused-argument """Verify get_all_tenants_info function.""" result = tenants.get_all_tenants_info() assert result['tenant_ids'] == [1, 2, 3, 7, 8] @@ -242,6 +244,20 @@ def test_get_all_tenants_info(base_data): (7, 's7.sample.com'), (8, 's8.sample.com'), ]) -def test_get_tenant_site(base_data, tenant_id, expected): +def test_get_tenant_site(base_data, tenant_id, expected): # pylint: disable=unused-argument """Verify get_tenant_site function.""" assert expected == tenants.get_tenant_site(tenant_id) + + +@pytest.mark.django_db +@pytest.mark.parametrize("org, expected", [ + ('ORG1', [1]), + ('ORG2', [1]), + ('ORG3', [2, 7]), + ('ORG4', [3]), + ('ORG5', [3]), + ('ORG8', [2, 8]), +]) +def test_get_tenants_by_org(base_data, org, expected): # pylint: disable=unused-argument + """Verify get_tenants_by_org function.""" + assert expected == tenants.get_tenants_by_org(org)