From 6e63620f45c3d20326b231feb63d50d0e91b9a63 Mon Sep 17 00:00:00 2001 From: Shadi Naif Date: Thu, 18 Apr 2024 17:47:04 +0300 Subject: [PATCH] feat: course details API --- .../dashboard/details/courses.py | 77 +++++++++++++++ .../dashboard/serializers.py | 92 +++++++++++++++++- futurex_openedx_extensions/dashboard/urls.py | 3 +- futurex_openedx_extensions/dashboard/views.py | 27 +++++- futurex_openedx_extensions/helpers/filters.py | 6 ++ futurex_openedx_extensions/helpers/tenants.py | 13 +++ .../eox_nelp/course_experience/models.py | 2 + .../edx_platform_mocks/fake_models/models.py | 36 +++++++ test_utils/edx_platform_mocks/setup.py | 2 +- test_utils/eox_settings.py | 8 +- tests/__init__.py | 0 tests/conftest.py | 1 + .../test_details/test_details_courses.py | 20 ++++ .../test_statistics/test_courses.py | 8 +- tests/test_dashboard/test_views.py | 93 ++++++++++++++++--- tests/test_helpers/test_filters.py | 10 ++ tests/test_helpers/test_pagination.py | 4 +- tests/test_helpers/test_tenants.py | 44 ++++++--- tox.ini | 3 + 19 files changed, 411 insertions(+), 38 deletions(-) create mode 100644 futurex_openedx_extensions/dashboard/details/courses.py create mode 100644 futurex_openedx_extensions/helpers/filters.py create mode 100644 test_utils/edx_platform_mocks/eox_nelp/course_experience/models.py delete mode 100644 tests/__init__.py create mode 100644 tests/test_dashboard/test_details/test_details_courses.py create mode 100644 tests/test_helpers/test_filters.py diff --git a/futurex_openedx_extensions/dashboard/details/courses.py b/futurex_openedx_extensions/dashboard/details/courses.py new file mode 100644 index 00000000..0f7ff5d6 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/details/courses.py @@ -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 diff --git a/futurex_openedx_extensions/dashboard/serializers.py b/futurex_openedx_extensions/dashboard/serializers.py index 99a4d1f5..c8d779cd 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,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 diff --git a/futurex_openedx_extensions/dashboard/urls.py b/futurex_openedx_extensions/dashboard/urls.py index 1bb15397..5bb69e89 100644 --- a/futurex_openedx_extensions/dashboard/urls.py +++ b/futurex_openedx_extensions/dashboard/urls.py @@ -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'), ] diff --git a/futurex_openedx_extensions/dashboard/views.py b/futurex_openedx_extensions/dashboard/views.py index 05c2d74f..d70fb647 100644 --- a/futurex_openedx_extensions/dashboard/views.py +++ b/futurex_openedx_extensions/dashboard/views.py @@ -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 @@ -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, + ) diff --git a/futurex_openedx_extensions/helpers/filters.py b/futurex_openedx_extensions/helpers/filters.py new file mode 100644 index 00000000..39d65910 --- /dev/null +++ b/futurex_openedx_extensions/helpers/filters.py @@ -0,0 +1,6 @@ +"""Filters helpers and classes for the API views.""" +from rest_framework.filters import OrderingFilter + + +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/test_utils/edx_platform_mocks/eox_nelp/course_experience/models.py b/test_utils/edx_platform_mocks/eox_nelp/course_experience/models.py new file mode 100644 index 00000000..3d9c88a2 --- /dev/null +++ b/test_utils/edx_platform_mocks/eox_nelp/course_experience/models.py @@ -0,0 +1,2 @@ +"""edx-platform Mocks""" +from fake_models.models import FeedbackCourse # pylint: disable=unused-import diff --git a/test_utils/edx_platform_mocks/fake_models/models.py b/test_utils/edx_platform_mocks/fake_models/models.py index 9dca4e23..b435d5d5 100644 --- a/test_utils/edx_platform_mocks/fake_models/models.py +++ b/test_utils/edx_platform_mocks/fake_models/models.py @@ -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" @@ -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"]] diff --git a/test_utils/edx_platform_mocks/setup.py b/test_utils/edx_platform_mocks/setup.py index 2353e348..b81a1482 100644 --- a/test_utils/edx_platform_mocks/setup.py +++ b/test_utils/edx_platform_mocks/setup.py @@ -4,5 +4,5 @@ setup( name='edx_platform_mocks', version='0.1.0', - packages=['common', 'fake_models', 'lms', 'openedx'], + packages=[], ) diff --git a/test_utils/eox_settings.py b/test_utils/eox_settings.py index 99a52e76..676c7971 100644 --- a/test_utils/eox_settings.py +++ b/test_utils/eox_settings.py @@ -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' diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/conftest.py b/tests/conftest.py index c53719a5..0aa8984e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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(): 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..65f43fbe --- /dev/null +++ b/tests/test_dashboard/test_details/test_details_courses.py @@ -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 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_dashboard/test_views.py b/tests/test_dashboard/test_views.py index b78c4da3..6a15ccd2 100644 --- a/tests/test_dashboard/test_views.py +++ b/tests/test_dashboard/test_views.py @@ -5,23 +5,33 @@ import pytest from django.contrib.auth import get_user_model from django.http import JsonResponse -from django.urls import reverse +from django.urls import resolve, reverse +from django.utils.timezone import now, timedelta +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from rest_framework.test import APITestCase +from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter from tests.base_test_data import expected_statistics -@pytest.mark.usefixtures('base_data') -class TestTotalCountsView(APITestCase): - """Tests for TotalCountsView""" +class BaseTextViewMixin(APITestCase): + """Base test view mixin""" + VIEW_NAME = 'view name is not set!' + def setUp(self): - self.url = reverse('fx_dashboard:total-counts') + self.url = reverse(self.VIEW_NAME) self.staff_user = 2 def login_user(self, user_id): """Helper to login user""" self.client.force_login(get_user_model().objects.get(id=user_id)) + +@pytest.mark.usefixtures('base_data') +class TestTotalCountsView(BaseTextViewMixin): + """Tests for TotalCountsView""" + VIEW_NAME = 'fx_dashboard:total-counts' + def test_unauthorized(self): """Test unauthorized access""" response = self.client.get(self.url) @@ -59,15 +69,9 @@ def test_selected_tenants(self): @pytest.mark.usefixtures('base_data') -class TestLearnersView(APITestCase): +class TestLearnersView(BaseTextViewMixin): """Tests for LearnersView""" - def setUp(self): - self.url = reverse('fx_dashboard:learners') - self.staff_user = 2 - - def login_user(self, user_id): - """Helper to login user""" - self.client.force_login(get_user_model().objects.get(id=user_id)) + VIEW_NAME = 'fx_dashboard:learners' def test_unauthorized(self): """Verify that the view returns 403 when the user is not authenticated""" @@ -95,3 +99,66 @@ def test_success(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data['count'], 46) self.assertGreater(len(response.data['results']), 0) + + +@pytest.mark.usefixtures('base_data') +class TesttCoursesView(BaseTextViewMixin): + """Tests for CoursesView""" + VIEW_NAME = 'fx_dashboard:courses' + + def test_unauthorized(self): + """Verify that the view returns 403 when the user is not authenticated""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + + def test_no_tenants(self): + """Verify that the view returns the result for all accessible tenants when no tenant IDs are provided""" + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_courses_queryset') as mock_queryset: + self.client.get(self.url) + mock_queryset.assert_called_once_with(tenant_ids=[1, 2, 3, 7, 8], search_text=None) + + def test_search(self): + """Verify that the view filters the courses by search text""" + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_courses_queryset') as mock_queryset: + self.client.get(self.url + '?tenant_ids=1&search_text=course') + mock_queryset.assert_called_once_with(tenant_ids=[1], search_text='course') + + def helper_test_success(self, response): + """Verify that the view returns the correct response""" + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['count'], 18) + self.assertGreater(len(response.data['results']), 0) + self.assertEqual(response.data['results'][0]['id'], 'course-v1:ORG1+1+1') + + def test_success(self): + """Verify that the view returns the correct response""" + self.login_user(self.staff_user) + response = self.client.get(self.url) + self.helper_test_success(response=response) + self.assertEqual(response.data['results'][0]['status'], 'active') + + def test_status_archived(self): + """Verify that the view sets the correct status when the course is archived""" + CourseOverview.objects.filter(id='course-v1:ORG1+1+1').update(end=now() - timedelta(days=1)) + + self.login_user(self.staff_user) + response = self.client.get(self.url) + self.helper_test_success(response=response) + self.assertEqual(response.data['results'][0]['status'], 'archived') + + def test_status_soon(self): + """Verify that the view sets the correct status when the course is soon""" + CourseOverview.objects.filter(id='course-v1:ORG1+1+1').update(start=now() + timedelta(days=1)) + + self.login_user(self.staff_user) + response = self.client.get(self.url) + self.helper_test_success(response=response) + self.assertEqual(response.data['results'][0]['status'], 'soon') + + def test_sorting(self): + """Verify that the view soring filter is set correctly""" + view_func, _, _ = resolve(self.url) + view_class = view_func.view_class + self.assertEqual(view_class.filter_backends, [DefaultOrderingFilter]) diff --git a/tests/test_helpers/test_filters.py b/tests/test_helpers/test_filters.py new file mode 100644 index 00000000..c53d9f7b --- /dev/null +++ b/tests/test_helpers/test_filters.py @@ -0,0 +1,10 @@ +"""Tests for pagination helpers""" +from rest_framework.filters import OrderingFilter + +from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter + + +def test_default_sorting_filter(): + """Verify that the DefaultOrderingFilter class is correctly defined.""" + assert issubclass(DefaultOrderingFilter, OrderingFilter) + assert DefaultOrderingFilter.ordering_param == 'sort' diff --git a/tests/test_helpers/test_pagination.py b/tests/test_helpers/test_pagination.py index 880d6dd1..22f78c06 100644 --- a/tests/test_helpers/test_pagination.py +++ b/tests/test_helpers/test_pagination.py @@ -1,10 +1,12 @@ """Tests for pagination helpers""" +from rest_framework.pagination import PageNumberPagination + from futurex_openedx_extensions.helpers.pagination import DefaultPagination def test_default_pagination(): """Verify that the DefaultPagination class is correctly defined.""" - assert issubclass(DefaultPagination, DefaultPagination) + assert issubclass(DefaultPagination, PageNumberPagination) assert DefaultPagination.page_size == 20 assert DefaultPagination.page_size_query_param == 'page_size' assert DefaultPagination.max_page_size == 100 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) diff --git a/tox.ini b/tox.ini index ca36bfcf..8398eb4b 100644 --- a/tox.ini +++ b/tox.ini @@ -35,12 +35,15 @@ addopts = --cov futurex_openedx_extensions --cov tests --cov-report term-missing norecursedirs = .* docs requirements site-packages [testenv] +allowlist_externals = + rm deps = django32: Django>=3.2,<4.0 django40: Django>=4.0,<4.1 -r{toxinidir}/requirements/test.txt -e{toxinidir}/test_utils/edx_platform_mocks commands = + rm -Rf {toxinidir}/test_utils/edx_platform_mocks/fake_models/migrations python manage.py makemigrations fake_models python manage.py check pytest {posargs}