diff --git a/futurex_openedx_extensions/dashboard/urls.py b/futurex_openedx_extensions/dashboard/urls.py index 56d72b97..bb0f1a59 100644 --- a/futurex_openedx_extensions/dashboard/urls.py +++ b/futurex_openedx_extensions/dashboard/urls.py @@ -9,6 +9,7 @@ app_name = 'fx_dashboard' urlpatterns = [ + re_path(r'^api/fx/accessible/v1/info/$', views.AccessibleTenantsInfoView.as_view(), name='accessible-info'), re_path(r'^api/fx/courses/v1/courses/$', views.CoursesView.as_view(), name='courses'), re_path(r'^api/fx/learners/v1/learners/$', views.LearnersView.as_view(), name='learners'), re_path( diff --git a/futurex_openedx_extensions/dashboard/views.py b/futurex_openedx_extensions/dashboard/views.py index 0ea6ed5e..8ebde36e 100644 --- a/futurex_openedx_extensions/dashboard/views.py +++ b/futurex_openedx_extensions/dashboard/views.py @@ -1,4 +1,6 @@ """Views for the dashboard app""" +from common.djangoapps.student.models import get_user_by_username_or_email +from django.core.exceptions import ObjectDoesNotExist from django.http import JsonResponse from rest_framework.generics import ListAPIView from rest_framework.response import Response @@ -15,7 +17,12 @@ from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter from futurex_openedx_extensions.helpers.pagination import DefaultPagination from futurex_openedx_extensions.helpers.permissions import HasTenantAccess, IsSystemStaff -from futurex_openedx_extensions.helpers.tenants import get_selected_tenants, get_user_id_from_username_tenants +from futurex_openedx_extensions.helpers.tenants import ( + get_accessible_tenant_ids, + get_selected_tenants, + get_tenants_info, + get_user_id_from_username_tenants, +) class TotalCountsView(APIView): @@ -219,3 +226,24 @@ def get(self, request, *args, **kwargs): # pylint: disable=no-self-use return JsonResponse({ 'version': futurex_openedx_extensions.__version__, }) + + +class AccessibleTenantsInfoView(APIView): + """View to get the list of accessible tenants""" + permission_classes = [] + + def get(self, request, *args, **kwargs): # pylint: disable=no-self-use + """ + GET /api/fx/tenants/v1/accessible_tenants/?username_or_email= + """ + username_or_email = request.query_params.get("username_or_email") + try: + user = get_user_by_username_or_email(username_or_email) + except ObjectDoesNotExist: + user = None + + if not user: + return JsonResponse({}) + + tenant_ids = get_accessible_tenant_ids(user) + return JsonResponse(get_tenants_info(tenant_ids)) diff --git a/futurex_openedx_extensions/helpers/constants.py b/futurex_openedx_extensions/helpers/constants.py index 970fe557..bf543d54 100644 --- a/futurex_openedx_extensions/helpers/constants.py +++ b/futurex_openedx_extensions/helpers/constants.py @@ -1,8 +1,13 @@ """Constants for the FutureX Open edX Extensions app.""" +CACHE_NAME_ALL_TENANTS_INFO = "all_tenants_info" +CACHE_NAME_ALL_COURSE_ORG_FILTER_LIST = "all_course_org_filter_list" + COURSE_STATUSES = { - 'active': 'active', - 'archived': 'archived', - 'upcoming': 'upcoming', + "active": "active", + "archived": "archived", + "upcoming": "upcoming", } -COURSE_STATUS_SELF_PREFIX = 'self_' +COURSE_STATUS_SELF_PREFIX = "self_" + +TENANT_LIMITED_ADMIN_ROLES = ["org_course_creator_group"] diff --git a/futurex_openedx_extensions/helpers/helpers.py b/futurex_openedx_extensions/helpers/helpers.py new file mode 100644 index 00000000..2fda232d --- /dev/null +++ b/futurex_openedx_extensions/helpers/helpers.py @@ -0,0 +1,9 @@ +"""Helper functions for FutureX Open edX Extensions.""" +from __future__ import annotations + +from typing import List + + +def get_first_not_empty_item(items: List, default=None) -> any: + """Return the first item in the list that is not empty.""" + return next((item for item in items if item), default) diff --git a/futurex_openedx_extensions/helpers/tenants.py b/futurex_openedx_extensions/helpers/tenants.py index 66b89738..3b7ce988 100644 --- a/futurex_openedx_extensions/helpers/tenants.py +++ b/futurex_openedx_extensions/helpers/tenants.py @@ -11,12 +11,12 @@ from eox_tenant.models import Route, TenantConfig from rest_framework.request import Request +from futurex_openedx_extensions.helpers import constants as cs from futurex_openedx_extensions.helpers.caching import cache_dict from futurex_openedx_extensions.helpers.converters import error_details_to_dictionary, ids_string_to_list +from futurex_openedx_extensions.helpers.helpers import get_first_not_empty_item from futurex_openedx_extensions.helpers.querysets import get_has_site_login_queryset -TENANT_LIMITED_ADMIN_ROLES = ['org_course_creator_group'] - def get_excluded_tenant_ids() -> List[int]: """ @@ -52,7 +52,7 @@ def get_all_tenants() -> QuerySet: return TenantConfig.objects.exclude(id__in=get_excluded_tenant_ids()) -@cache_dict(timeout=settings.FX_CACHE_TIMEOUT_TENANTS_INFO, key_generator_or_name='all_tenants_info') +@cache_dict(timeout=settings.FX_CACHE_TIMEOUT_TENANTS_INFO, key_generator_or_name=cs.CACHE_NAME_ALL_TENANTS_INFO) def get_all_tenants_info() -> Dict[str, Any]: """ Get all tenants in the system that are exposed in the route table, and with a valid config @@ -62,13 +62,33 @@ def get_all_tenants_info() -> Dict[str, Any]: :return: Dictionary of tenant IDs and Sites :rtype: Dict[str, Any] """ + def _fix_lms_base(domain_name: str) -> str: + """Fix the LMS base URL""" + if not domain_name: + return '' + return f'https://{domain_name}' if not domain_name.startswith('http') else domain_name + tenant_ids = list(get_all_tenants().values_list('id', flat=True)) - info = TenantConfig.objects.filter(id__in=tenant_ids).values('id', 'route__domain') + info = TenantConfig.objects.filter(id__in=tenant_ids).values('id', 'route__domain', 'lms_configs') return { 'tenant_ids': tenant_ids, 'sites': { tenant['id']: tenant['route__domain'] for tenant in info - } + }, + 'info': { + tenant['id']: { + 'lms_root_url': get_first_not_empty_item([ + (tenant['lms_configs'].get('LMS_ROOT_URL') or '').strip(), + _fix_lms_base((tenant['lms_configs'].get('LMS_BASE') or '').strip()), + _fix_lms_base((tenant['lms_configs'].get('SITE_NAME') or '').strip()), + ], default=''), + 'platform_name': get_first_not_empty_item([ + (tenant['lms_configs'].get('PLATFORM_NAME') or '').strip(), + (tenant['lms_configs'].get('platform_name') or '').strip(), + ], default=''), + 'logo_image_url': (tenant['lms_configs'].get('logo_image_url') or '').strip(), + } for tenant in info + }, } @@ -82,6 +102,19 @@ def get_all_tenant_ids() -> List[int]: return get_all_tenants_info()['tenant_ids'] +def get_tenants_info(tenant_ids: List[int]) -> Dict[int, Any]: + """ + Get the information for the given tenant IDs + + :param tenant_ids: List of tenant IDs to get the information for + :type tenant_ids: List[int] + :return: Dictionary of tenant information + :rtype: Dict[str, Any] + """ + all_tenants_info = get_all_tenants_info() + return {t_id: all_tenants_info['info'].get(t_id) for t_id in tenant_ids} + + def get_tenant_site(tenant_id: int) -> str: """ Get the site for a tenant @@ -94,7 +127,9 @@ def get_tenant_site(tenant_id: int) -> str: return get_all_tenants_info()['sites'].get(tenant_id) -@cache_dict(timeout=settings.FX_CACHE_TIMEOUT_TENANTS_INFO, key_generator_or_name='all_course_org_filter_list') +@cache_dict( + timeout=settings.FX_CACHE_TIMEOUT_TENANTS_INFO, key_generator_or_name=cs.CACHE_NAME_ALL_COURSE_ORG_FILTER_LIST +) def get_all_course_org_filter_list() -> Dict[int, List[str]]: """ Get all course org filters for all tenants. @@ -188,7 +223,7 @@ def get_accessible_tenant_ids(user: get_user_model()) -> List[int]: course_org_filter_list = get_all_course_org_filter_list() accessible_orgs = CourseAccessRole.objects.filter( user_id=user.id, - role__in=TENANT_LIMITED_ADMIN_ROLES, + role__in=cs.TENANT_LIMITED_ADMIN_ROLES, ).values_list('org', flat=True).distinct() return [t_id for t_id, course_org_filter in course_org_filter_list.items() if any( diff --git a/test_utils/edx_platform_mocks/common/djangoapps/student/models.py b/test_utils/edx_platform_mocks/common/djangoapps/student/models.py index b0edf594..99cc004f 100644 --- a/test_utils/edx_platform_mocks/common/djangoapps/student/models.py +++ b/test_utils/edx_platform_mocks/common/djangoapps/student/models.py @@ -1,4 +1,5 @@ """edx-platform Mocks""" +from fake_models.functions import get_user_by_username_or_email # pylint: disable=unused-import from fake_models.models import ( # pylint: disable=unused-import CourseAccessRole, CourseEnrollment, diff --git a/test_utils/edx_platform_mocks/fake_models/functions.py b/test_utils/edx_platform_mocks/fake_models/functions.py index 261a74e8..892c330b 100644 --- a/test_utils/edx_platform_mocks/fake_models/functions.py +++ b/test_utils/edx_platform_mocks/fake_models/functions.py @@ -29,3 +29,8 @@ def get_certificates_for_user_by_course_keys(user, course_keys): # pylint: disa if not isinstance(user, get_user_model()): raise TypeError(f'Expects a user object but got "{user}" of type "{type(user)}"') return {} + + +def get_user_by_username_or_email(username_or_email): + """get_user_by_username_or_email Mock""" + raise get_user_model().DoesNotExist('Dummy function always returns DoesNotExist, mock it you need it') diff --git a/tests/test_base_test_data.py b/tests/test_base_test_data.py index d6c00a4f..50caef81 100644 --- a/tests/test_base_test_data.py +++ b/tests/test_base_test_data.py @@ -1,5 +1,5 @@ """Test integrity of base test data.""" -from futurex_openedx_extensions.helpers.tenants import TENANT_LIMITED_ADMIN_ROLES +from futurex_openedx_extensions.helpers.constants import TENANT_LIMITED_ADMIN_ROLES from tests.base_test_data import _base_data diff --git a/tests/test_dashboard/test_views.py b/tests/test_dashboard/test_views.py index 4f735e7b..8660d803 100644 --- a/tests/test_dashboard/test_views.py +++ b/tests/test_dashboard/test_views.py @@ -381,3 +381,42 @@ def test_success(self): response = self.client.get(self.url) self.assertEqual(response.status_code, 200) self.assertEqual(json.loads(response.content), {'version': '0.1.dummy'}) + + +@pytest.mark.usefixtures("base_data") +class TestAccessibleTenantsInfoView(BaseTestViewMixin): + """Tests for AccessibleTenantsInfoView""" + VIEW_NAME = "fx_dashboard:accessible-info" + + def test_permission_classes(self): + """Verify that the view has the correct permission classes""" + view_func, _, _ = resolve(self.url) + view_class = view_func.view_class + self.assertEqual(view_class.permission_classes, []) + + @patch("futurex_openedx_extensions.dashboard.views.get_user_by_username_or_email") + def test_success(self, mock_get_user): + """Verify that the view returns the correct response""" + mock_get_user.return_value = get_user_model().objects.get(username="user4") + response = self.client.get(self.url, data={"username_or_email": "dummy, the user loader function is mocked"}) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(json.loads(response.content), { + '1': {'lms_root_url': 'https://s1.sample.com', 'platform_name': '', 'logo_image_url': ''}, + '2': {'lms_root_url': 'https://s2.sample.com', 'platform_name': '', 'logo_image_url': ''}, + '7': {'lms_root_url': 'https://s7.sample.com', 'platform_name': '', 'logo_image_url': ''} + }) + + @patch("futurex_openedx_extensions.dashboard.views.get_user_by_username_or_email") + def test_no_username_or_email(self, mock_get_user): + """Verify that the view returns the correct response""" + mock_get_user.side_effect = get_user_model().DoesNotExist() + response = self.client.get(self.url) + mock_get_user.assert_called_once_with(None) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(json.loads(response.content), {}) + + def test_not_existing_username_or_email(self): + """Verify that the view returns the correct response""" + response = self.client.get(self.url, data={"username_or_email": "dummy"}) + self.assertEqual(response.status_code, 200) + self.assertDictEqual(json.loads(response.content), {}) diff --git a/tests/test_helpers/test_helpers.py b/tests/test_helpers/test_helpers.py new file mode 100644 index 00000000..c4912c5d --- /dev/null +++ b/tests/test_helpers/test_helpers.py @@ -0,0 +1,20 @@ +"""Tests for the helper functions in the helpers module.""" +import pytest + +from futurex_openedx_extensions.helpers.helpers import get_first_not_empty_item + + +@pytest.mark.parametrize("items, expected, error_message", [ + ([0, None, False, "", 3, "hello"], 3, "Test with a list containing truthy and falsy values"), + ([0, None, False, ""], None, "Test with a list containing only falsy values"), + ([1, "a", [1], {1: 1}], 1, "Test with a list containing only truthy values"), + ([], None, "Test with an empty list"), + ([0, [], {}, (), "non-empty"], "non-empty", "Test with a list containing mixed types"), + ([[], {}, (), 5], 5, "Test with a list containing different truthy types"), + ([None, "test"], "test", "Test with None as an element"), + ([[None, []], [], [1, 2, 3]], [None, []], "Test with nested lists"), + (["first", 0, None, False, "", 3, "hello"], "first", "Test with first element truthy") +]) +def test_get_first_not_empty_item(items, expected, error_message): + """Verify that the get_first_not_empty_item function returns the first non-empty item in the list.""" + assert get_first_not_empty_item(items) == expected, f"Failed: {error_message}" diff --git a/tests/test_helpers/test_tenants.py b/tests/test_helpers/test_tenants.py index 887ddadf..176f91c9 100644 --- a/tests/test_helpers/test_tenants.py +++ b/tests/test_helpers/test_tenants.py @@ -1,4 +1,5 @@ """Tests for tenants helpers.""" +from unittest.mock import patch import pytest from common.djangoapps.student.models import CourseEnrollment, UserSignupSource @@ -7,8 +8,8 @@ from django.test import override_settings from eox_tenant.models import TenantConfig +from futurex_openedx_extensions.helpers import constants as cs from futurex_openedx_extensions.helpers import tenants -from futurex_openedx_extensions.helpers.tenants import TENANT_LIMITED_ADMIN_ROLES from tests.base_test_data import _base_data @@ -100,7 +101,7 @@ def test_get_accessible_tenant_ids_complex(base_data): # pylint: disable=unused for role, orgs in _base_data["course_access_roles"].items(): for org, users in orgs.items(): - if role not in TENANT_LIMITED_ADMIN_ROLES: + if role not in cs.TENANT_LIMITED_ADMIN_ROLES: continue if role != user_access_role or org != user_access: assert user.id not in users, ( @@ -113,7 +114,7 @@ def test_get_accessible_tenant_ids_complex(base_data): # pylint: disable=unused f'{user_access_role} for {user_access}' ) assert ( - (role not in TENANT_LIMITED_ADMIN_ROLES) or + (role not in cs.TENANT_LIMITED_ADMIN_ROLES) or (role != user_access_role and user.id not in users) or (org != user_access and user.id not in users) or (role == user_access_role and org == user_access and user.id in users) @@ -172,9 +173,9 @@ def test_get_all_course_org_filter_list(base_data): # pylint: disable=unused-ar @pytest.mark.django_db def test_get_all_course_org_filter_list_is_being_cached(): """Verify that get_all_course_org_filter_list is being cached.""" - assert cache.get('all_course_org_filter_list') is None + assert cache.get(cs.CACHE_NAME_ALL_COURSE_ORG_FILTER_LIST) is None result = tenants.get_all_course_org_filter_list() - assert cache.get('all_course_org_filter_list') == result + assert cache.get(cs.CACHE_NAME_ALL_COURSE_ORG_FILTER_LIST) == result @pytest.mark.django_db @@ -245,13 +246,65 @@ def test_get_all_tenants_info(base_data): # pylint: disable=unused-argument } +@pytest.mark.django_db +@pytest.mark.parametrize("config_key, info_key, test_value, expected_result", [ + ("LMS_BASE", "lms_root_url", "lms.example.com", "https://lms.example.com"), + ("LMS_ROOT_URL", "lms_root_url", "https://lms.example.com", "https://lms.example.com"), + ("SITE_NAME", "lms_root_url", "lms.example.com", "https://lms.example.com"), + ("PLATFORM_NAME", "platform_name", "Test Platform", "Test Platform"), + ("platform_name", "platform_name", "Test Platform", "Test Platform"), + ("logo_image_url", "logo_image_url", "https://img.example.com/dummy.jpg", "https://img.example.com/dummy.jpg"), +]) +@patch('futurex_openedx_extensions.helpers.tenants.get_excluded_tenant_ids', return_value=[]) +def test_get_all_tenants_info_configs( + base_data, config_key, info_key, test_value, expected_result +): # pylint: disable=unused-argument + """Verify get_all_tenants_info function returning the correct logo_url.""" + tenant_config = TenantConfig.objects.create() + assert tenant_config.lms_configs.get(config_key) is None + + result = tenants.get_all_tenants_info() + assert result["info"][tenant_config.id][info_key] == "" + + tenant_config.lms_configs[config_key] = test_value + tenant_config.save() + result = tenants.get_all_tenants_info() + assert result["info"][tenant_config.id][info_key] == expected_result + + +@pytest.mark.django_db +@pytest.mark.parametrize("config_keys, data_prefix, call_index", [ + (["LMS_ROOT_URL", "LMS_BASE", "SITE_NAME"], "https://", 0), + (["PLATFORM_NAME", "platform_name"], "", 1), +]) +@patch( + 'futurex_openedx_extensions.helpers.tenants.get_excluded_tenant_ids', + return_value=[1, 2, 3, 4, 5, 6, 7, 8] +) +@patch('futurex_openedx_extensions.helpers.tenants.get_first_not_empty_item') +def test_get_all_tenants_info_config_priorities( + mock_get_first_not_empty_item, base_data, config_keys, data_prefix, call_index +): # pylint: disable=unused-argument + """Verify get_all_tenants_info is respecting the priority of the config keys.""" + assert not tenants.get_all_tenants_info()["tenant_ids"] + tenant_config = TenantConfig.objects.create() + for config_key in config_keys: + tenant_config.lms_configs[config_key] = f"{data_prefix}{config_key}_value" + tenant_config.save() + + _ = tenants.get_all_tenants_info() + assert mock_get_first_not_empty_item.call_args_list[call_index][0][0] == [ + f"{data_prefix}{config_key}_value" for config_key in config_keys + ] + + @override_settings(CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}) @pytest.mark.django_db def test_get_all_tenants_info_is_being_cached(): """Verify that get_all_tenants_info is being cached.""" - assert cache.get('all_tenants_info') is None + assert cache.get(cs.CACHE_NAME_ALL_TENANTS_INFO) is None result = tenants.get_all_tenants_info() - assert cache.get('all_tenants_info') == result + assert cache.get(cs.CACHE_NAME_ALL_TENANTS_INFO) == result @pytest.mark.django_db