Skip to content

Commit

Permalink
feat: get accessible tenant API
Browse files Browse the repository at this point in the history
  • Loading branch information
shadinaif committed Jun 12, 2024
1 parent a65b329 commit 6f63342
Show file tree
Hide file tree
Showing 11 changed files with 216 additions and 20 deletions.
1 change: 1 addition & 0 deletions futurex_openedx_extensions/dashboard/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
30 changes: 29 additions & 1 deletion futurex_openedx_extensions/dashboard/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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=<usernameOrEmail>
"""
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))
13 changes: 9 additions & 4 deletions futurex_openedx_extensions/helpers/constants.py
Original file line number Diff line number Diff line change
@@ -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"]
9 changes: 9 additions & 0 deletions futurex_openedx_extensions/helpers/helpers.py
Original file line number Diff line number Diff line change
@@ -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)
49 changes: 42 additions & 7 deletions futurex_openedx_extensions/helpers/tenants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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
Expand All @@ -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
},
}


Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
5 changes: 5 additions & 0 deletions test_utils/edx_platform_mocks/fake_models/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
2 changes: 1 addition & 1 deletion tests/test_base_test_data.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down
39 changes: 39 additions & 0 deletions tests/test_dashboard/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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), {})
20 changes: 20 additions & 0 deletions tests/test_helpers/test_helpers.py
Original file line number Diff line number Diff line change
@@ -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}"
67 changes: 60 additions & 7 deletions tests/test_helpers/test_tenants.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Tests for tenants helpers."""
from unittest.mock import patch

import pytest
from common.djangoapps.student.models import CourseEnrollment, UserSignupSource
Expand All @@ -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


Expand Down Expand Up @@ -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, (
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 6f63342

Please sign in to comment.