Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add library roles support for roles API #124

Merged
merged 1 commit into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions futurex_openedx_extensions/helpers/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@

CLICKHOUSE_QUERY_SLUG_PATTERN = r'[a-z0-9_\-.]+'

COURSE_ID_PART = r'[a-zA-Z0-9_-]+'
ID_PART = r'[a-zA-Z0-9_-]+'
COURSE_ID_REGX = \
fr'(?P<course_id>course-v1:(?P<org>{COURSE_ID_PART})\+(?P<course>{COURSE_ID_PART})\+(?P<run>{COURSE_ID_PART}))'
fr'(?P<course_id>course-v1:(?P<org>{ID_PART})\+(?P<course>{ID_PART})\+(?P<run>{ID_PART}))'
COURSE_ID_REGX_EXACT = rf'^{COURSE_ID_REGX}$'

LIBRARY_ID_REGX = \
fr'(?P<course_id>library-v1:(?P<org>{ID_PART})\+(?P<code>{ID_PART}))'
LIBRARY_ID_REGX_EXACT = rf'^{LIBRARY_ID_REGX}$'

COURSE_STATUSES = {
'active': 'active',
'archived': 'archived',
Expand All @@ -57,10 +61,12 @@
]

COURSE_ACCESS_ROLES_STAFF_EDITOR = 'staff'
COURSE_ACCESS_ROLES_LIBRARY_USER = 'library_user'

COURSE_ACCESS_ROLES_TENANT_OR_COURSE = [
'data_researcher',
'instructor',
COURSE_ACCESS_ROLES_LIBRARY_USER,
COURSE_ACCESS_ROLES_STAFF_EDITOR,
]
COURSE_ACCESS_ROLES_GLOBAL = [
Expand All @@ -78,7 +84,6 @@
COURSE_ACCESS_ROLES_ACCEPT_COURSE_ID = COURSE_ACCESS_ROLES_COURSE_ONLY + COURSE_ACCESS_ROLES_TENANT_OR_COURSE

COURSE_ACCESS_ROLES_UNSUPPORTED = [
'library_user', # not supported yet, it requires a library ID instead of a course ID
'sales_admin', # won't be supported, looks like a deprecated role, there is no useful code for it
]

Expand Down
9 changes: 8 additions & 1 deletion futurex_openedx_extensions/helpers/extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from urllib.parse import urlparse

from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from xmodule.modulestore.django import modulestore

from futurex_openedx_extensions.helpers import constants as cs

Expand Down Expand Up @@ -122,7 +123,7 @@ def verify_course_ids(course_ids: List[str]) -> None:
for course_id in course_ids:
if not isinstance(course_id, str):
raise ValueError(f'course_id must be a string, but got {type(course_id).__name__}')
if not re.search(cs.COURSE_ID_REGX_EXACT, course_id):
if not re.search(cs.COURSE_ID_REGX_EXACT, course_id) and not re.search(cs.LIBRARY_ID_REGX_EXACT, course_id):
raise ValueError(f'Invalid course ID format: {course_id}')


Expand All @@ -144,6 +145,12 @@ def get_orgs_of_courses(course_ids: List[str]) -> Dict[str, Any]:
'courses': {str(course.id): course.org.lower() for course in courses},
}

result['courses'].update({
str(lib_key): lib_key.org
for lib_key in modulestore().get_library_keys()
if str(lib_key) in course_ids
})
Comment on lines +148 to +152
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought this was bad, then realized it's great! thank you!


invalid_course_ids = [course_id for course_id in course_ids if course_id not in result['courses']]
if invalid_course_ids:
raise ValueError(f'Invalid course IDs provided: {invalid_course_ids}')
Expand Down
22 changes: 22 additions & 0 deletions futurex_openedx_extensions/helpers/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
import re
from copy import deepcopy
from enum import Enum
from typing import Any, Dict, List, Tuple
Expand Down Expand Up @@ -44,6 +45,19 @@
logger = logging.getLogger(__name__)


def are_all_library_ids(course_ids: List[str] | None) -> bool:
"""
Verify if all given ids are library ids
"""
if not course_ids:
return False

for course_id in course_ids:
if not re.match(cs.LIBRARY_ID_REGX, course_id):
return False
return True
Comment on lines +55 to +58
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for course_id in course_ids:
if not re.match(cs.LIBRARY_ID_REGX, course_id):
return False
return True
return all(re.match(cs.LIBRARY_ID_REGX, course_id) for course_id in course_ids)



def validate_course_access_role(course_access_role: dict) -> None:
"""
Validate the course access role.
Expand All @@ -60,6 +74,11 @@ def check_broken_rule(broken_rule: bool, code: FXExceptionCodes, message: str) -
org = course_access_role['org'].strip()
course_id = course_access_role['course_id']
role = course_access_role['role']
check_broken_rule(
role == cs.COURSE_ACCESS_ROLES_LIBRARY_USER and course_id and not are_all_library_ids([course_id]),
FXExceptionCodes.ROLE_INVALID_ENTRY,
f'role {role} is only valid with library_id',
)
Comment on lines +77 to +81
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validation order is a bit important in this function. Please move this after ....f'role {role} must have at least an org, it can also have a course_id!',


check_broken_rule(
role not in cs.COURSE_ACCESS_ROLES_ALL,
Expand Down Expand Up @@ -1155,6 +1174,9 @@ def add_course_access_roles( # pylint: disable=too-many-arguments, too-many-bra
if (tenant_wide and course_ids) or (not tenant_wide and not course_ids):
raise ValueError('Conflict between tenant_wide and course_ids')

if role == cs.COURSE_ACCESS_ROLES_LIBRARY_USER and course_ids and not are_all_library_ids(course_ids):
raise ValueError(f'Role: {role} is only valid for library ids.')

if not user_keys:
raise ValueError('No users provided!')

Expand Down
17 changes: 17 additions & 0 deletions test_utils/edx_platform_mocks/xmodule/modulestore/django.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Mock modulestore"""
from opaque_keys.edx.keys import CourseKey


class DummyModuleStore: # pylint: disable=too-few-public-methods
"""DUmmy module store class"""
def __init__(self):
self.ids = ['library-v1:org1+11', 'library-v1:org1+22']

def get_library_keys(self):
"""mock modulestore library keys method"""
return [CourseKey.from_string(id) for id in self.ids]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mocking is our enemy in tests. Please keep close to the real result as much as possible

Suggested change
return [CourseKey.from_string(id) for id in self.ids]
return [LibraryLocator.from_string(id) for id in self.ids]

from opaque_keys.edx.locator import LibraryLocator



def modulestore():
"""mocked modulestore"""
return DummyModuleStore()
55 changes: 49 additions & 6 deletions tests/test_helpers/test_extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,22 @@ def test_get_course_id_from_uri(uri, expected_course_id):
assert get_course_id_from_uri(uri) == expected_course_id


def test_verify_course_ids_success():
"""Verify that verify_course_ids does not raise an error for valid course IDs."""
course_ids = ['course-v1:edX+DemoX+Demo_Course', 'course-v1:edX+DemoX+Demo_Course2']
@pytest.mark.parametrize('course_ids', [
(['course-v1:edX+DemoX+Demo_Course', 'course-v1:edX+DemoX+Demo_Course2']),
(['library-v1:DemoX+11', 'library-v1:DemoX+22']),
(['course-v1:edX+DemoX+Demo_Course', 'library-v1:DemoX+22']),
])
def test_verify_course_ids_success(course_ids):
"""Verify that verify_course_ids does not raise an error for valid course and library IDs."""
verify_course_ids(course_ids)


@pytest.mark.parametrize('course_ids, error_message', [
(None, 'course_ids must be a list of course IDs, but got None'),
(['course-v1:edX+DemoX+Demo_Course', 3], 'course_id must be a string, but got int'),
(['course-v1:edX+DemoX+Demo_Course+extra'], 'Invalid course ID format: course-v1:edX+DemoX+Demo_Course+extra'),
(['library-v1:edX+DemoX+extra'], 'Invalid course ID format: library-v1:edX+DemoX+extra'),
(['library-v1:invalid'], 'Invalid course ID format: library-v1:invalid'),
])
def test_verify_course_ids_fail(course_ids, error_message):
"""Verify that verify_course_ids raises an error for invalid course IDs."""
Expand All @@ -83,11 +89,48 @@ def test_get_orgs_of_courses(base_data): # pylint: disable=unused-argument


@pytest.mark.django_db
def test_get_orgs_of_courses_invalid_course(base_data): # pylint: disable=unused-argument
@pytest.mark.parametrize('course_ids, expected_orgs', [
(['course-v1:Org1+1+1', 'course-v1:ORG1+2+2'], {
'course-v1:Org1+1+1': 'org1',
'course-v1:ORG1+2+2': 'org1',
}),
(['library-v1:org1+11', 'course-v1:ORG1+5+5'], {
'library-v1:org1+11': 'org1',
'course-v1:ORG1+5+5': 'org1',
}),
(['library-v1:org1+11', 'library-v1:org1+22'], {
'library-v1:org1+11': 'org1',
'library-v1:org1+22': 'org1',
}),
])
def test_get_orgs_of_courses_for_library_ids(course_ids, expected_orgs, base_data): # pylint: disable=unused-argument
"""Verify that get_orgs_of_courses returns the expected organization for library ids."""
result = get_orgs_of_courses(course_ids)
assert result == {'courses': expected_orgs}


@pytest.mark.django_db
@pytest.mark.parametrize('course_ids, expected_error_message', [
(
['course-v1:ORG1+2+99'],
'Invalid course IDs provided: [\'course-v1:ORG1+2+99\']'
),
(
['library-v1:not_exist+11'],
'Invalid course IDs provided: [\'library-v1:not_exist+11\']'
),
(
['course-v1:ORG1+2+99', 'library-v1:not_exist+1'],
'Invalid course IDs provided: [\'course-v1:ORG1+2+99\', \'library-v1:not_exist+1\']'
)
])
def test_get_orgs_of_courses_invalid_course(
course_ids, expected_error_message, base_data
): # pylint: disable=unused-argument
"""Verify that get_orgs_of_courses raises an error for an invalid course ID."""
with pytest.raises(ValueError) as exc_info:
get_orgs_of_courses(['course-v1:ORG1+2+99'])
assert str(exc_info.value) == 'Invalid course IDs provided: [\'course-v1:ORG1+2+99\']'
get_orgs_of_courses(course_ids)
assert str(exc_info.value) == expected_error_message


def test_dict_hashcode_init():
Expand Down
55 changes: 54 additions & 1 deletion tests/test_helpers/test_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
add_course_access_roles,
add_missing_signup_source_record,
add_org_course_creator,
are_all_library_ids,
cache_name_user_course_access_roles,
cache_refresh_course_access_roles,
check_tenant_access,
Expand Down Expand Up @@ -65,6 +66,20 @@ def reset_fx_views_with_roles():
FXViewRoleInfoMetaClass._fx_views_with_roles = {'_all_view_names': {}} # pylint: disable=protected-access


@pytest.mark.parametrize('course_ids, expected_return, usecase', [
(None, False, 'course_ids list is None'),
([], False, 'course_ids list is empty'),
(['course-v1:ORG1+1+1', 'course-v1:ORG1+1+1'], False, 'course_ids contain courses'),
(['library-v1:ORG1+1', 'course-v1:ORG1+1+1'], False, 'course_ids contain libraries and courses'),
(['library-v1:ORG1+11', 'library-v1:ORG1+22'], True, 'course_ids contain valid library'),
(['library-v1:ORG1+11', 'invalid-library-id'], False, 'course_ids contain invalid library'),
])
def test_are_all_library_ids(course_ids, expected_return, usecase):
"""test are_all_librarary_ids"""
return_value = are_all_library_ids(course_ids)
assert return_value is expected_return, f'Failed usecase: {usecase}'


@pytest.mark.parametrize('update_course_access_role, error_msg, error_code', [
({'role': 'bad_role'}, 'invalid role ({role})!', FXExceptionCodes.ROLE_INVALID_ENTRY),
(
Expand Down Expand Up @@ -117,6 +132,11 @@ def reset_fx_views_with_roles():
'missing course-creator record for {role} role!',
FXExceptionCodes.ROLE_INVALID_ENTRY,
),
(
{'role': cs.COURSE_ACCESS_ROLES_LIBRARY_USER, 'course_id': 'yes'},
'role {role} is only valid with library_id',
FXExceptionCodes.ROLE_INVALID_ENTRY
),
])
@pytest.mark.django_db
def test_validate_course_access_role_invalid(
Expand All @@ -132,7 +152,7 @@ def test_validate_course_access_role_invalid(
}
bad_course_access_role.update(update_course_access_role)
if bad_course_access_role['course_id']:
bad_course_access_role['course_id'] = CourseKey.from_string('course-v1:ORG1+1+1')
bad_course_access_role['course_id'] = 'course-v1:ORG1+1+1'

if error_msg and '{role}' in error_msg:
error_msg = error_msg.format(role=update_course_access_role['role'])
Expand Down Expand Up @@ -1316,6 +1336,15 @@ def test_add_course_access_roles_tenant_wide_role(roles_authorize_caller): # py
assert str(exc_info.value) == f'Role ({role}) can only be tenant-wide!'


@pytest.mark.django_db
def test_add_course_access_roles_for_library_user(roles_authorize_caller): # pylint: disable=unused-argument
"""Verify that add_course_access_roles raises an error adding library_user role with course_id"""
role = cs.COURSE_ACCESS_ROLES_LIBRARY_USER
with pytest.raises(FXCodedException) as exc_info:
add_course_access_roles(None, [1, 8], ['user1'], role, False, ['course-v1:ORG3+1+1'])
assert str(exc_info.value) == f'Role: {role} is only valid for library ids.'


@pytest.mark.django_db
@pytest.mark.parametrize('user_keys, error_message', [
(None, 'No users provided!'),
Expand Down Expand Up @@ -1411,6 +1440,30 @@ def test_add_course_access_roles_success_list_contains_user_key(
}


@pytest.mark.django_db
@pytest.mark.parametrize('role, library_ids, tenant_wide', [
('library_user', ['library-v1:org1+11'], False),
('library_user', [], True),
('library_user', None, True),
('staff', ['library-v1:org1+11'], False),
('instructor', ['library-v1:org1+11'], False)
])
@patch('futurex_openedx_extensions.helpers.users.get_user_by_username_or_email')
def test_add_course_access_roles_success_for_library_roles(
mock_get_user, role, library_ids, tenant_wide, base_data
): # pylint: disable=unused-argument
"""Verify that add_course_access_roles for library roles"""
caller = get_user_model().objects.get(username='user1')
mock_get_user.return_value = get_user_model().objects.get(id=3)
result = add_course_access_roles(caller, [1], ['user3'], role, tenant_wide, library_ids)
assert result == {
'failed': [],
'added': [],
'updated': ['user3'],
'not_updated': [],
}


@pytest.mark.django_db
def test_add_course_access_roles_success_user_key_same_as_id(
roles_authorize_caller, base_data
Expand Down