Skip to content

Commit 2e8accb

Browse files
[FC-0099] feat: assign library roles after successful library creation (openedx#37532)
1 parent f3822a1 commit 2e8accb

8 files changed

Lines changed: 473 additions & 19 deletions

File tree

openedx/core/djangoapps/content_libraries/api/libraries.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
from openedx_learning.api.authoring_models import Component
6666
from organizations.models import Organization
6767
from xblock.core import XBlock
68+
from openedx_authz.api import assign_role_to_user_in_scope
6869

6970
from openedx.core.types import User as UserType
7071

@@ -105,6 +106,7 @@
105106
"get_allowed_block_types",
106107
"publish_changes",
107108
"revert_changes",
109+
"assign_library_role_to_user",
108110
]
109111

110112

@@ -153,6 +155,12 @@ class AccessLevel:
153155
NO_ACCESS = None
154156

155157

158+
ACCESS_LEVEL_TO_LIBRARY_ROLE = {
159+
AccessLevel.ADMIN_LEVEL: "library_admin",
160+
AccessLevel.AUTHOR_LEVEL: "library_author",
161+
}
162+
163+
156164
@dataclass(frozen=True)
157165
class ContentLibraryPermissionEntry:
158166
"""
@@ -515,6 +523,30 @@ def set_library_user_permissions(library_key: LibraryLocatorV2, user: UserType,
515523
)
516524

517525

526+
def assign_library_role_to_user(library_key: LibraryLocatorV2, user: UserType, access_level: str):
527+
"""Grant a role to the specified user for this library.
528+
529+
Args:
530+
library_key (LibraryLocatorV2): The key of the content library.
531+
user (UserType): The user to whom the role will be granted.
532+
access_level (str | None): The access level to be granted. This access level maps to a specific role.
533+
534+
Raises:
535+
TypeError: If the user is an instance of AnonymousUser.
536+
"""
537+
if isinstance(user, AnonymousUser):
538+
raise TypeError("Invalid user type")
539+
540+
role = ACCESS_LEVEL_TO_LIBRARY_ROLE.get(access_level)
541+
if role is None:
542+
raise ValueError(f"Invalid access level: {access_level}")
543+
544+
if assign_role_to_user_in_scope(user.username, role, str(library_key)):
545+
log.info(f"Assigned role '{role}' to user '{user.username}' for library '{library_key}'")
546+
else:
547+
log.warning(f"Failed to assign role '{role}' to user '{user.username}' for library '{library_key}'")
548+
549+
518550
def set_library_group_permissions(library_key: LibraryLocatorV2, group, access_level: str):
519551
"""
520552
Change the specified group's level of access to this library.

openedx/core/djangoapps/content_libraries/rest_api/libraries.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,12 @@ def post(self, request):
243243
result = api.create_library(org=org, **data)
244244
# Grant the current user admin permissions on the library:
245245
api.set_library_user_permissions(result.key, request.user, api.AccessLevel.ADMIN_LEVEL)
246+
247+
# Grant the current user the library admin role for this library.
248+
# Other role assignments are handled by openedx-authz and the Console MFE.
249+
# This ensures the creator has access to new libraries. From the library views,
250+
# users can then manage roles for others.
251+
api.assign_library_role_to_user(result.key, request.user, api.AccessLevel.ADMIN_LEVEL)
246252
except api.LibraryAlreadyExists:
247253
raise ValidationError(detail={"slug": "A library with that ID already exists."}) # lint-amnesty, pylint: disable=raise-missing-from
248254

openedx/core/djangoapps/content_libraries/tests/test_api.py

Lines changed: 303 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@
2525
LIBRARY_COLLECTION_UPDATED,
2626
LIBRARY_CONTAINER_UPDATED,
2727
)
28+
from openedx_authz.api.users import get_user_role_assignments_in_scope
2829
from openedx_learning.api import authoring as authoring_api
2930

31+
from common.djangoapps.student.tests.factories import UserFactory
3032
from .. import api
3133
from ..models import ContentLibrary
3234
from .base import ContentLibrariesRestApiTest
@@ -859,8 +861,304 @@ def test_delete_component_and_revert(self):
859861
api.revert_changes(self.lib1.library_key)
860862

861863
assert container_event_receiver.call_count == 1
862-
assert {
863-
"signal": LIBRARY_CONTAINER_UPDATED,
864-
"sender": None,
865-
"library_container": LibraryContainerData(container_key=self.unit1.container_key),
866-
}.items() <= container_event_receiver.call_args_list[0].kwargs.items()
864+
self.assertDictContainsEntries(
865+
container_event_receiver.call_args_list[0].kwargs,
866+
{
867+
"signal": LIBRARY_CONTAINER_UPDATED,
868+
"sender": None,
869+
"library_container": LibraryContainerData(
870+
container_key=self.unit3.container_key
871+
),
872+
},
873+
)
874+
875+
def test_copy_and_paste_container_same_library(self) -> None:
876+
# Copy a section with children
877+
api.copy_container(self.section1.container_key, self.user.id)
878+
# Paste the container
879+
new_container: api.ContainerMetadata = (
880+
api.import_staged_content_from_user_clipboard(self.lib1.library_key, self.user) # type: ignore[assignment]
881+
)
882+
883+
# Verify that the container is copied
884+
assert new_container.container_type == self.section1.container_type
885+
assert new_container.display_name == self.section1.display_name
886+
887+
# Verify that the children are linked
888+
subsections = api.get_container_children(new_container.container_key)
889+
assert len(subsections) == 2
890+
assert isinstance(subsections[0], api.ContainerMetadata)
891+
assert subsections[0].container_key == self.subsection1.container_key
892+
assert isinstance(subsections[1], api.ContainerMetadata)
893+
assert subsections[1].container_key == self.subsection2.container_key
894+
895+
def test_copy_and_paste_container_another_library(self) -> None:
896+
# Copy a section with children
897+
api.copy_container(self.section1.container_key, self.user.id)
898+
899+
self._create_library("test-lib-cont-2", "Test Library 2")
900+
lib2 = ContentLibrary.objects.get(slug="test-lib-cont-2")
901+
# Paste the container
902+
new_container: api.ContainerMetadata = (
903+
api.import_staged_content_from_user_clipboard(lib2.library_key, self.user) # type: ignore[assignment]
904+
)
905+
906+
# Verify that the container is copied
907+
assert new_container.container_type == self.section1.container_type
908+
assert new_container.display_name == self.section1.display_name
909+
910+
# Verify that the children are copied
911+
subsections = api.get_container_children(new_container.container_key)
912+
assert len(subsections) == 2
913+
assert isinstance(subsections[0], api.ContainerMetadata)
914+
assert subsections[0].container_key != self.subsection1.container_key # This subsection was copied
915+
assert subsections[0].display_name == self.subsection1.display_name
916+
units_subsection1 = api.get_container_children(subsections[0].container_key)
917+
assert len(units_subsection1) == 2
918+
assert isinstance(units_subsection1[0], api.ContainerMetadata)
919+
assert units_subsection1[0].container_key != self.unit1.container_key # This unit was copied
920+
assert units_subsection1[0].display_name == self.unit1.display_name == "Unit 1"
921+
unit1_components = api.get_container_children(units_subsection1[0].container_key)
922+
assert len(unit1_components) == 2
923+
assert isinstance(unit1_components[0], api.LibraryXBlockMetadata)
924+
assert unit1_components[0].usage_key != self.problem_block_usage_key # This component was copied
925+
assert isinstance(unit1_components[1], api.LibraryXBlockMetadata)
926+
assert unit1_components[1].usage_key != self.html_block_usage_key # This component was copied
927+
928+
assert isinstance(units_subsection1[1], api.ContainerMetadata)
929+
assert units_subsection1[1].container_key != self.unit2.container_key # This unit was copied
930+
assert units_subsection1[1].display_name == self.unit2.display_name == "Unit 2"
931+
unit2_components = api.get_container_children(units_subsection1[1].container_key)
932+
assert len(unit2_components) == 1
933+
assert isinstance(unit2_components[0], api.LibraryXBlockMetadata)
934+
assert unit2_components[0].usage_key != self.html_block_usage_key
935+
936+
# This is the same component, so it should not be duplicated
937+
assert unit1_components[1].usage_key == unit2_components[0].usage_key
938+
939+
assert isinstance(subsections[1], api.ContainerMetadata)
940+
assert subsections[1].container_key != self.subsection2.container_key # This subsection was copied
941+
assert subsections[1].display_name == self.subsection2.display_name
942+
units_subsection2 = api.get_container_children(subsections[1].container_key)
943+
assert len(units_subsection2) == 1
944+
assert isinstance(units_subsection2[0], api.ContainerMetadata)
945+
assert units_subsection2[0].container_key != self.unit1.container_key # This unit was copied
946+
assert units_subsection2[0].display_name == self.unit1.display_name
947+
948+
# This is the same unit, so it should not be duplicated
949+
assert units_subsection1[0].container_key == units_subsection2[0].container_key
950+
951+
952+
class ContentLibraryExportTest(ContentLibrariesRestApiTest):
953+
"""
954+
Tests for Content Library API export methods.
955+
"""
956+
957+
def setUp(self) -> None:
958+
super().setUp()
959+
960+
# Create Content Libraries
961+
self._create_library("test-lib-exp-1", "Test Library Export 1")
962+
963+
# Fetch the created ContentLibrary objects so we can access their learning_package.id
964+
self.lib1 = ContentLibrary.objects.get(slug="test-lib-exp-1")
965+
self.wrong_task_id = '11111111-1111-1111-1111-111111111111'
966+
967+
def test_get_backup_task_status_no_task(self) -> None:
968+
status = api.get_backup_task_status(self.user.id, "")
969+
assert status is None
970+
971+
def test_get_backup_task_status_wrong_task_id(self) -> None:
972+
status = api.get_backup_task_status(self.user.id, task_id=self.wrong_task_id)
973+
assert status is None
974+
975+
def test_get_backup_task_status_in_progress(self) -> None:
976+
# Create a mock UserTaskStatus in IN_PROGRESS state
977+
task_id = str(uuid.uuid4())
978+
mock_task = UserTaskStatus(
979+
task_id=task_id,
980+
user_id=self.user.id,
981+
name=f"Export of {self.lib1.library_key}",
982+
state=UserTaskStatus.IN_PROGRESS
983+
)
984+
985+
with mock.patch(
986+
'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskStatus.objects.get'
987+
) as mock_get:
988+
mock_get.return_value = mock_task
989+
990+
status = api.get_backup_task_status(self.user.id, task_id=task_id)
991+
assert status is not None
992+
assert status['state'] == UserTaskStatus.IN_PROGRESS
993+
assert status['file'] is None
994+
995+
def test_get_backup_task_status_succeeded(self) -> None:
996+
# Create a mock UserTaskStatus in SUCCEEDED state
997+
task_id = str(uuid.uuid4())
998+
mock_task = UserTaskStatus(
999+
task_id=task_id,
1000+
user_id=self.user.id,
1001+
name=f"Export of {self.lib1.library_key}",
1002+
state=UserTaskStatus.SUCCEEDED
1003+
)
1004+
1005+
# Create a mock UserTaskArtifact
1006+
mock_artifact = mock.Mock()
1007+
mock_artifact.file.url = "/media/user_tasks/2025/10/01/library-libOEXCSPROB_mOw1rPL.zip"
1008+
1009+
with mock.patch(
1010+
'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskStatus.objects.get'
1011+
) as mock_get, mock.patch(
1012+
'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskArtifact.objects.get'
1013+
) as mock_artifact_get:
1014+
1015+
mock_get.return_value = mock_task
1016+
mock_artifact_get.return_value = mock_artifact
1017+
1018+
status = api.get_backup_task_status(self.user.id, task_id=task_id)
1019+
assert status is not None
1020+
assert status['state'] == UserTaskStatus.SUCCEEDED
1021+
assert status['file'].url == "/media/user_tasks/2025/10/01/library-libOEXCSPROB_mOw1rPL.zip"
1022+
1023+
def test_get_backup_task_status_failed(self) -> None:
1024+
# Create a mock UserTaskStatus in FAILED state
1025+
task_id = str(uuid.uuid4())
1026+
mock_task = UserTaskStatus(
1027+
task_id=task_id,
1028+
user_id=self.user.id,
1029+
name=f"Export of {self.lib1.library_key}",
1030+
state=UserTaskStatus.FAILED
1031+
)
1032+
1033+
with mock.patch(
1034+
'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskStatus.objects.get'
1035+
) as mock_get:
1036+
mock_get.return_value = mock_task
1037+
1038+
status = api.get_backup_task_status(self.user.id, task_id=task_id)
1039+
assert status is not None
1040+
assert status['state'] == UserTaskStatus.FAILED
1041+
assert status['file'] is None
1042+
1043+
1044+
class ContentLibraryAuthZRoleAssignmentTest(ContentLibrariesRestApiTest):
1045+
"""
1046+
Tests for Content Library role assignment via the AuthZ Authorization Framework.
1047+
1048+
These tests verify that library roles are correctly assigned to users through
1049+
the openedx-authz (AuthZ) Authorization Framework when libraries are created or when
1050+
explicit role assignments are made.
1051+
1052+
See: https://github.com/openedx/openedx-authz/
1053+
"""
1054+
1055+
def setUp(self) -> None:
1056+
super().setUp()
1057+
1058+
# Create Content Libraries
1059+
self._create_library("test-lib-role-1", "Test Library Role 1")
1060+
1061+
# Fetch the created ContentLibrary objects so we can access their learning_package.id
1062+
self.lib1 = ContentLibrary.objects.get(slug="test-lib-role-1")
1063+
1064+
def test_assign_library_admin_role_to_user_via_authz(self) -> None:
1065+
"""
1066+
Test assigning a library admin role to a user via the AuthZ Authorization Framework.
1067+
1068+
This test verifies that the openedx-authz Authorization Framework correctly
1069+
assigns the library_admin role to a user when explicitly called.
1070+
"""
1071+
api.assign_library_role_to_user(self.lib1.library_key, self.user, api.AccessLevel.ADMIN_LEVEL)
1072+
1073+
roles = get_user_role_assignments_in_scope(self.user.username, str(self.lib1.library_key))
1074+
assert len(roles) == 1
1075+
assert "library_admin" in repr(roles[0].roles[0])
1076+
1077+
def test_assign_library_author_role_to_user_via_authz(self) -> None:
1078+
"""
1079+
Test assigning a library author role to a user via the AuthZ Authorization Framework.
1080+
1081+
This test verifies that the openedx-authz Authorization Framework correctly
1082+
assigns the library_author role to a user when explicitly called.
1083+
"""
1084+
# Create a new user to avoid conflicts with roles assigned during library creation
1085+
author_user = UserFactory.create(username="Author", email="[email protected]")
1086+
1087+
api.assign_library_role_to_user(self.lib1.library_key, author_user, api.AccessLevel.AUTHOR_LEVEL)
1088+
1089+
roles = get_user_role_assignments_in_scope(author_user.username, str(self.lib1.library_key))
1090+
assert len(roles) == 1
1091+
assert "library_author" in repr(roles[0].roles[0])
1092+
1093+
@mock.patch("openedx.core.djangoapps.content_libraries.api.libraries.assign_role_to_user_in_scope")
1094+
def test_library_creation_assigns_admin_role_via_authz(
1095+
self,
1096+
mock_assign_role
1097+
) -> None:
1098+
"""
1099+
Test that creating a library via REST API assigns admin role via AuthZ.
1100+
1101+
This test verifies that when a library is created via the REST API,
1102+
the creator is automatically assigned the library_admin role through
1103+
the openedx-authz Authorization Framework.
1104+
"""
1105+
mock_assign_role.return_value = True
1106+
1107+
# Create a new library (this should trigger role assignment in the REST API)
1108+
self._create_library("test-lib-role-2", "Test Library Role 2")
1109+
1110+
# Verify that assign_role_to_user_in_scope was called
1111+
mock_assign_role.assert_called_once()
1112+
call_args = mock_assign_role.call_args
1113+
assert call_args[0][0] == self.user.username # username
1114+
assert call_args[0][1] == "library_admin" # role
1115+
assert "test-lib-role-2" in call_args[0][2] # library_key (contains slug)
1116+
1117+
@mock.patch("openedx.core.djangoapps.content_libraries.api.libraries.assign_role_to_user_in_scope")
1118+
def test_library_creation_handles_authz_failure_gracefully(
1119+
self,
1120+
mock_assign_role
1121+
) -> None:
1122+
"""
1123+
Test that library creation succeeds even if AuthZ role assignment fails.
1124+
1125+
This test verifies that if the openedx-authz Authorization Framework fails to assign
1126+
a role (returns False), the library creation still succeeds. This ensures that
1127+
the system degrades gracefully and doesn't break library creation if there are
1128+
issues with the Authorization Framework.
1129+
"""
1130+
# Simulate openedx-authz failing to assign the role
1131+
mock_assign_role.return_value = False
1132+
1133+
# Library creation should still succeed
1134+
result = self._create_library("test-lib-role-3", "Test Library Role 3")
1135+
assert result is not None
1136+
assert result["slug"] == "test-lib-role-3"
1137+
1138+
# Verify that the library was created successfully
1139+
lib3 = ContentLibrary.objects.get(slug="test-lib-role-3")
1140+
assert lib3 is not None
1141+
assert lib3.slug == "test-lib-role-3"
1142+
1143+
@mock.patch("openedx.core.djangoapps.content_libraries.api.libraries.assign_role_to_user_in_scope")
1144+
def test_library_creation_handles_authz_exception(
1145+
self,
1146+
mock_assign_role
1147+
) -> None:
1148+
"""
1149+
Test that library creation succeeds even if AuthZ raises an exception.
1150+
1151+
This test verifies that if the openedx-authz Authorization Framework raises an
1152+
exception during role assignment, the library creation still succeeds. This ensures
1153+
robust error handling when the Authorization Framework is unavailable or misconfigured.
1154+
"""
1155+
# Simulate openedx-authz raising an exception for unknown issues
1156+
mock_assign_role.side_effect = Exception("AuthZ unavailable")
1157+
1158+
# Library creation should still succeed (the exception should be caught/handled)
1159+
# Note: Currently, the code doesn't catch this exception, so we expect it to propagate.
1160+
# This test documents the current behavior and can be updated if error handling is added.
1161+
with self.assertRaises(Exception) as context:
1162+
self._create_library("test-lib-role-4", "Test Library Role 4")
1163+
1164+
assert "AuthZ unavailable" in str(context.exception)

0 commit comments

Comments
 (0)