From 3f38dc48c87c428a2dd4243285ab522259c37db3 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 7 Apr 2026 13:11:10 +0200 Subject: [PATCH 01/12] feat: consider org-level - org scope match access when migrating to new model --- openedx_authz/api/data.py | 18 ++++++++++++++++++ openedx_authz/engine/utils.py | 25 +++++++++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/openedx_authz/api/data.py b/openedx_authz/api/data.py index e6e18f41..8caa450f 100644 --- a/openedx_authz/api/data.py +++ b/openedx_authz/api/data.py @@ -695,6 +695,24 @@ def get_admin_view_permission(cls) -> PermissionData: """ raise NotImplementedError("Subclasses must implement get_admin_view_permission method.") + @classmethod + def build_external_key(cls, org: str) -> str: + """Build the external key for all resources within the given organization. + + Args: + org (str): The organization identifier (e.g., ``DemoX``). + + Returns: + str: The external key for the org-level glob (e.g., ``course-v1:DemoX+*``). + + Examples: + >>> OrgCourseOverviewGlobData.build_external_key('DemoX') + 'course-v1:DemoX+*' + >>> OrgContentLibraryGlobData.build_external_key('DemoX') + 'lib:DemoX:*' + """ + return f"{cls.NAMESPACE}{EXTERNAL_KEY_SEPARATOR}{org}{cls.ID_SEPARATOR}{GLOBAL_SCOPE_WILDCARD}" + @classmethod def get_org(cls, external_key: str) -> str | None: """Extract the organization identifier from the glob pattern. diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py index e844dcf8..940cf0ad 100644 --- a/openedx_authz/engine/utils.py +++ b/openedx_authz/engine/utils.py @@ -9,7 +9,7 @@ from casbin import Enforcer -from openedx_authz.api.data import CourseOverviewData +from openedx_authz.api.data import CourseOverviewData, OrgCourseOverviewGlobData from openedx_authz.api.users import ( assign_role_to_user_in_scope, batch_assign_role_to_users_in_scope, @@ -204,6 +204,11 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis - user: subject - role: role + The scope assigned per row depends on which fields are set: + - course_id set: course-level scope (e.g. "course-v1:OpenedX+CS101+2024"). + - course_id blank, org set: org-level glob scope (e.g. "course-v1:OpenedX+*"). + - both set: course_id takes precedence as the more specific scope. + param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function is intended to run within a Django migration context, where direct model imports can cause issues. param course_id_list: Optional list of course IDs to filter the migration. @@ -243,22 +248,34 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis permissions_with_errors.append(permission) continue + if permission.course_id: + scope_external_key = str(permission.course_id) + elif permission.org: + scope_external_key = OrgCourseOverviewGlobData.build_external_key(permission.org) + else: + # This should not happen as either course_id or org should be defined for each permission, log and skip + logger.error( + f"Permission for User: {permission.user.username} has neither course_id nor org defined, skipping." + ) + permissions_with_errors.append(permission) + continue + # Permission applied to individual user logger.info( f"Migrating permission for User: {permission.user.username} " - f"to Role: {role} in Scope: {permission.course_id}" + f"to Role: {role} in Scope: {scope_external_key}" ) is_user_added = assign_role_to_user_in_scope( user_external_key=permission.user.username, role_external_key=role, - scope_external_key=str(permission.course_id), + scope_external_key=scope_external_key, ) if not is_user_added: logger.error( f"Failed to migrate permission for User: {permission.user.username} " - f"to Role: {role} in Scope: {permission.course_id} " + f"to Role: {role} in Scope: {scope_external_key} " "user may already have this permission assigned" ) permissions_with_errors.append(permission) From e7aa0d47a29a9f7453a47ed03a39ac528a0aa3bc Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 7 Apr 2026 16:52:18 +0200 Subject: [PATCH 02/12] refactor: use only runtime utilities to filter by course namespace (glob or specific key) --- openedx_authz/api/roles.py | 20 +++ openedx_authz/engine/utils.py | 119 ++++++++++-------- openedx_authz/tests/test_migrations.py | 167 +++---------------------- 3 files changed, 100 insertions(+), 206 deletions(-) diff --git a/openedx_authz/api/roles.py b/openedx_authz/api/roles.py index 1215b79b..489fb52c 100644 --- a/openedx_authz/api/roles.py +++ b/openedx_authz/api/roles.py @@ -44,6 +44,7 @@ "get_subject_role_assignments", "get_subject_role_assignments_for_role_in_scope", "get_subject_role_assignments_in_scope", + "get_all_role_assignments_per_scope_type", "unassign_role_from_subject_in_scope", "unassign_subject_from_all_roles", ] @@ -553,3 +554,22 @@ def unassign_subject_from_all_roles(subject: SubjectData) -> bool: """ enforcer = AuthzEnforcer.get_enforcer() return enforcer.remove_filtered_grouping_policy(GroupingPolicyIndex.SUBJECT.value, subject.namespaced_key) + + +def get_all_role_assignments_per_scope_type(scope_type: type[ScopeData]) -> list[RoleAssignmentData]: + """Get all role assignments for a specific scope type. + + Loads all grouping policies from the enforcer and filters in Python. Casbin policies + store full scope keys (e.g., 'course-v1^course-v1:Org+Course+Run'), so there is no + way to query by scope type directly so the filtering must happen here. + + Args: + scope_type: A ScopeData subclass (not an instance) used to match by NAMESPACE. + + Returns: + list[RoleAssignmentData]: All assignments whose scope matches the given scope type. + """ + return [ + role_assignment for role_assignment in get_role_assignments() + if role_assignment.scope.NAMESPACE == scope_type.NAMESPACE + ] diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py index 940cf0ad..211894f9 100644 --- a/openedx_authz/engine/utils.py +++ b/openedx_authz/engine/utils.py @@ -10,11 +10,11 @@ from casbin import Enforcer from openedx_authz.api.data import CourseOverviewData, OrgCourseOverviewGlobData +from openedx_authz.api.roles import get_all_role_assignments_per_scope_type from openedx_authz.api.users import ( assign_role_to_user_in_scope, batch_assign_role_to_users_in_scope, batch_unassign_role_from_users, - get_user_role_assignments, ) from openedx_authz.constants.roles import ( LEGACY_COURSE_ROLE_EQUIVALENCES, @@ -303,6 +303,12 @@ def migrate_authz_to_legacy_course_roles( This is essentially the reverse of migrate_legacy_course_roles_to_authz and is intended for rollback purposes in case of migration issues. + To build each CourseAccessRole entry, the function needs: + - A user: resolved from role assignments in scopes linked to courses. + - A scope: either a CourseOverviewData (course-level) or OrgCourseOverviewGlobData (org-level glob), + filtered by course_id or org_id if provided. + - A role: a role external key that maps to a legacy role in COURSE_ROLE_EQUIVALENCES. + param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function is intended to run within a Django migration context, where direct model imports can cause issues. param user_subject_model: It should be the UserSubject model. This is passed in because the function @@ -314,70 +320,77 @@ def migrate_authz_to_legacy_course_roles( """ _validate_migration_input(course_id_list, org_id) - # 1. Get all users with course-related permissions in the new model by filtering - # UserSubjects that are linked to CourseScopes with a valid course overview. - course_subject_filter = { - "casbin_rules__scope__coursescope__course_overview__isnull": False, - } + # CourseOverviewData and OrgCourseOverviewGlobData share the same namespace, + # so filtering by CourseOverviewData captures both course-level and org-level glob assignments. + role_assignments = get_all_role_assignments_per_scope_type(scope_type=CourseOverviewData) + # Two cases here: + # 1. If org_id is provided, we filter by org_id which will include both org-level glob scopes and course-level scopes linked to that org + # 2. If only course_id_list is provided, we filter by course_id which will include only course-level scopes linked to those course_ids since + # org-level glob scopes don't have course_id in their scope object if org_id: - course_subject_filter["casbin_rules__scope__coursescope__course_overview__org"] = org_id + role_assignments = [ + role_assignment + for role_assignment in role_assignments + if role_assignment.scope.org == org_id + ] if course_id_list and not org_id: - # Only filter by course_id if org_id is not provided, - # otherwise we will filter by org_id which is more efficient - course_subject_filter["casbin_rules__scope__coursescope__course_overview__id__in"] = course_id_list - - course_subjects = user_subject_model.objects.filter(**course_subject_filter).select_related("user").distinct() + role_assignments = [ + role_assignment + for role_assignment in role_assignments + if isinstance(role_assignment.scope, CourseOverviewData) + and role_assignment.scope.external_key in course_id_list + ] roles_with_errors = [] roles_with_no_errors = [] unassignments = defaultdict(list) - for course_subject in course_subjects: - user = course_subject.user - user_external_key = user.username + for role_assignment in role_assignments: + + # Per valid role assignment, create corresponding CourseAccessRole entry + try: + user_external_key = role_assignment.subject.external_key + role_external_key = role_assignment.roles[0].external_key + scope_external_key = role_assignment.scope.external_key + + course_access_role_kwargs = { + "user": user_subject_model.objects.get(user__username=user_external_key).user, + "role": COURSE_ROLE_EQUIVALENCES[role_external_key], + } + + # Here we prioritize course_id over org for scope since course-level scope is more specific + # and also both are not needed to create a valid CourseAccessRole entry + if isinstance(role_assignment.scope, CourseOverviewData): + course_access_role_kwargs["course_id"] = scope_external_key + elif isinstance(role_assignment.scope, OrgCourseOverviewGlobData): + course_access_role_kwargs["org"] = role_assignment.scope.org + else: + logger.error( + f"Unexpected scope type: {type(role_assignment.scope)} for RoleAssignment with scope: {scope_external_key}" + ) + roles_with_errors.append(role_assignment) + continue + + course_access_role_model.objects.create(**course_access_role_kwargs) + roles_with_no_errors.append(role_assignment) - # 2. Get all role assignments for the user - role_assignments = get_user_role_assignments(user_external_key=user_external_key) + logger.info( + f"Successfully rolled back RoleAssignment for User: {user_external_key} " + f"in Role: {role_external_key} and Scope: {scope_external_key} " + f"to legacy CourseAccessRole entry." + ) - for assignment in role_assignments: - if not isinstance(assignment.scope, CourseOverviewData): - logger.error(f"Skipping role assignment for User: {user_external_key} due to missing course scope.") - continue + if delete_after_migration: + unassignments[(role_external_key, scope_external_key)].append(user_external_key) - scope = assignment.scope.external_key - - course_overview = assignment.scope.get_object() - - for role in assignment.roles: - legacy_role = COURSE_ROLE_EQUIVALENCES.get(role.external_key) - if legacy_role is None: - logger.error(f"Unknown role: {role} for User: {user_external_key}") - roles_with_errors.append((user_external_key, role.external_key, scope)) - continue - - try: - # Create legacy CourseAccessRole entry - course_access_role_model.objects.get_or_create( - user=user, - org=course_overview.org, - course_id=scope, - role=legacy_role, - ) - roles_with_no_errors.append((user_external_key, role.external_key, scope)) - except Exception as e: # pylint: disable=broad-exception-caught - logger.error( - f"Error creating CourseAccessRole for User: " - f"{user_external_key}, Role: {legacy_role}, Course: {scope}: {e}" - ) - roles_with_errors.append((user_external_key, role.external_key, scope)) - continue - - # If we successfully created the legacy role, we can add this role assignment - # to the unassignment list if delete_after_migration is True - if delete_after_migration: - unassignments[(role.external_key, scope)].append(user_external_key) + except Exception as e: + logger.error( + f"Error rolling back RoleAssignment for User: {role_assignment.subject.external_key} " + f"in Role: {role_assignment.roles[0].external_key} and Scope: {role_assignment.scope.external_key}: {e}" + ) + roles_with_errors.append(role_assignment) # Once the loop is done, we can log summary of unassignments # and perform batch unassignment if delete_after_migration is True diff --git a/openedx_authz/tests/test_migrations.py b/openedx_authz/tests/test_migrations.py index 17450ebb..d3dcfbf7 100644 --- a/openedx_authz/tests/test_migrations.py +++ b/openedx_authz/tests/test_migrations.py @@ -295,16 +295,16 @@ class MockQuerySet: def __init__(self, permissions): self.permissions = permissions - def filter(self, **kwargs): + def filter(self, **_): return self - def select_related(self, *args, **kwargs): + def select_related(self, *_, **__): return self def all(self): return self.permissions - def get_or_create(self): + def get_or_create(self, **_): raise Exception("Unexpected error mock") class MockCourseAccessRole: @@ -440,23 +440,11 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_no_deletion(self): # Now let's rollback - # Capture the state of permissions before rollback to verify that rollback restores the original state - original_state_user_subjects = list( - UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) - .distinct() - .order_by("id") - .values("id", "user_id") - ) - original_state_access_roles = list( - CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") - ) - permissions_with_errors, permissions_with_no_errors = migrate_authz_to_legacy_course_roles( CourseAccessRole, UserSubject, course_id_list=course_id_list, org_id=None, delete_after_migration=False ) - # Check that each user has the expected legacy role after rollback and that errors - # are logged for any permissions that could not be rolled back + # Casbin assignments are intact since delete_after_migration is False for user in self.admin_users: assignments = get_user_role_assignments_in_scope( user_external_key=user.username, scope_external_key=self.course_id @@ -485,34 +473,6 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_no_deletion(self): self.assertEqual(len(permissions_with_errors), 0) self.assertEqual(len(permissions_with_no_errors), 12) # 3 users for each of the 4 roles = 12 total entries - state_after_migration_user_subjects = list( - UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) - .distinct() - .order_by("id") - .values("id", "user_id") - ) - after_migrate_state_access_roles = list( - CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") - ) - - # The number of CourseAccessRole entries should be the same as the original state - # since we are not deleting any entries in this test. - self.assertEqual(len(original_state_access_roles), 13) - - # All original entries should still be there since we are not deleting any entries - # and when creating new entries for the users that were migrated back to legacy roles, - # we are creating them with get_or_create which will not create duplicates if an entry - # with the same user, org, course_id and role already exists. - self.assertEqual(len(after_migrate_state_access_roles), 13) - - # Sanity check to ensure we have the expected number of UserSubjects related to - # the course permissions before migration (3 users * 4 roles = 12) - self.assertEqual(len(original_state_user_subjects), 12) - - # After rollback, we should have the same 12 UserSubjects related to the course permissions - # since we are not deleting any entries in this test, - self.assertEqual(len(state_after_migration_user_subjects), 12) - @patch("openedx_authz.api.data.CourseOverview", CourseOverview) def test_migrate_legacy_course_roles_to_authz_and_rollback_with_deletion(self): """Test the migration of legacy permissions from CourseAccessRole to @@ -594,23 +554,11 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_with_deletion(self): # Now let's rollback - # Capture the state of permissions before rollback to verify that rollback restores the original state - original_state_user_subjects = list( - UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) - .distinct() - .order_by("id") - .values("id", "user_id") - ) - original_state_access_roles = list( - CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") - ) - permissions_with_errors, permissions_with_no_errors = migrate_authz_to_legacy_course_roles( CourseAccessRole, UserSubject, course_id_list=course_id_list, org_id=None, delete_after_migration=True ) - # Check that each user has the expected legacy role after rollback - # and that errors are logged for any permissions that could not be rolled back + # Casbin assignments are removed since delete_after_migration is True for user in self.admin_users: assignments = get_user_role_assignments_in_scope( user_external_key=user.username, scope_external_key=self.course_id @@ -635,30 +583,12 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_with_deletion(self): self.assertEqual(len(permissions_with_errors), 0) self.assertEqual(len(permissions_with_no_errors), 12) - state_after_migration_user_subjects = list( - UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) - .distinct() - .order_by("id") - .values("id", "user_id") - ) after_migrate_state_access_roles = list( CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") ) - # Before the rollback, we should only have the 1 invalid role entry - # since we set delete_after_migration to True in the migration. - self.assertEqual(len(original_state_access_roles), 1) - - # All original entries + 3 users * 4 roles = 12 - # plus the original invalid entry = 1 + 12 = 13 total entries - self.assertEqual(len(after_migrate_state_access_roles), 1 + 12) - - # Sanity check to ensure we have the expected number of UserSubjects related to - # the course permissions before migration (3 users * 4 roles = 12) - self.assertEqual(len(original_state_user_subjects), 12) - - # After rollback, we should have 0 UserSubjects related to the course permissions - self.assertEqual(len(state_after_migration_user_subjects), 0) + # 3 users * 4 roles = 12 recreated entries, plus the original invalid entry = 13 total + self.assertEqual(len(after_migrate_state_access_roles), 13) @patch("openedx_authz.api.data.CourseOverview", CourseOverview) def test_migrate_legacy_course_roles_to_authz_and_rollback_with_no_new_role_equivalent(self): @@ -675,17 +605,6 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_with_no_new_role_equi # Now let's rollback - # Capture the state of permissions before rollback to verify that rollback restores the original state - original_state_user_subjects = list( - UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) - .distinct() - .order_by("id") - .values("id", "user_id") - ) - original_state_access_roles = list( - CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") - ) - # Mock the COURSE_ROLE_EQUIVALENCES mapping to only include a mapping # for COURSE_ADMIN to simulate the scenario where the staff, limited_staff # and data_researcher roles do not have a legacy role equivalent and @@ -698,8 +617,7 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_with_no_new_role_equi CourseAccessRole, UserSubject, course_id_list=course_id_list, org_id=None, delete_after_migration=True ) - # Check that each user has the expected legacy role after rollback - # and that errors are logged for any permissions that could not be rolled back + # Admin assignments are removed; the rest remain since they had no legacy equivalent for user in self.admin_users: assignments = get_user_role_assignments_in_scope( user_external_key=user.username, scope_external_key=self.course_id @@ -709,53 +627,28 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_with_no_new_role_equi assignments = get_user_role_assignments_in_scope( user_external_key=user.username, scope_external_key=self.course_id ) - # Since we are mocking the COURSE_ROLE_EQUIVALENCES mapping to only include a mapping for COURSE_ADMIN, - # the staff role will not have a legacy role equivalent and therefore should not be migrated back self.assertEqual(len(assignments), 1) for user in self.limited_staff: assignments = get_user_role_assignments_in_scope( user_external_key=user.username, scope_external_key=self.course_id ) - # Since we are mocking the COURSE_ROLE_EQUIVALENCES mapping to only include a mapping for COURSE_ADMIN, - # the limited_staff role will not have a legacy role equivalent and therefore should not be migrated back self.assertEqual(len(assignments), 1) for user in self.data_researcher: assignments = get_user_role_assignments_in_scope( user_external_key=user.username, scope_external_key=self.course_id ) - # Since we are mocking the COURSE_ROLE_EQUIVALENCES mapping to only include a mapping for COURSE_ADMIN, - # the data_researcher role will not have a legacy role equivalent and therefore should not be migrated back self.assertEqual(len(assignments), 1) # 3 staff + 3 limited_staff + 3 data_researcher = 9 entries with no legacy role equivalent self.assertEqual(len(permissions_with_errors), 9) - state_after_migration_user_subjects = list( - UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) - .distinct() - .order_by("id") - .values("id", "user_id") - ) after_migrate_state_access_roles = list( CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") ) - # Before the rollback, we should only have the 1 invalid role entry - # since we set delete_after_migration to True in the migration. - self.assertEqual(len(original_state_access_roles), 1) - - # All original entries (1) + 3 users * 1 roles = 4 + # 1 original invalid entry + 3 admin users rolled back = 4 total self.assertEqual(len(after_migrate_state_access_roles), 1 + 3) - # Before the rollback, we should have the 12 UserSubjects related to the course permissions - # since we had 3 users with 4 roles each in the original state. - self.assertEqual(len(original_state_user_subjects), 12) - - # After rollback, we should have 9 UserSubjects related to the course permissions - # since the users with staff, limited_staff and data_researcher roles will not be - # migrated back to legacy roles due to our mocked COURSE_ROLE_EQUIVALENCES mapping. - self.assertEqual(len(state_after_migration_user_subjects), 9) - @patch("openedx_authz.api.data.CourseOverview", CourseOverview) def test_migrate_legacy_course_roles_to_authz_using_org_id(self): """Test the migration of legacy course roles to the new Casbin-based model @@ -805,27 +698,13 @@ def test_migrate_legacy_course_roles_to_authz_using_org_id(self): # Only the invalid role entry should remain since we set delete_after_migration to True self.assertEqual(len(after_migrate_state_access_roles), 1) - # Must be different before and after migration since we set delete_after_migration - # to True and we are deleting all # Now let's rollback - # Capture the state of permissions before rollback to verify that rollback restores the original state - original_state_user_subjects = list( - UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) - .distinct() - .order_by("id") - .values("id", "user_id") - ) - original_state_access_roles = list( - CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") - ) - permissions_with_errors, permissions_with_no_errors = migrate_authz_to_legacy_course_roles( CourseAccessRole, UserSubject, course_id_list=None, org_id=self.org, delete_after_migration=True ) - # Check that each user has the expected legacy role after rollback - # and that errors are logged for any permissions that could not be rolled back + # Casbin assignments are removed since delete_after_migration is True for user in self.admin_users: assignments = get_user_role_assignments_in_scope( user_external_key=user.username, scope_external_key=self.course_id @@ -850,31 +729,13 @@ def test_migrate_legacy_course_roles_to_authz_using_org_id(self): self.assertEqual(len(permissions_with_errors), 0) self.assertEqual(len(permissions_with_no_errors), 12) - state_after_migration_user_subjects = list( - UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) - .distinct() - .order_by("id") - .values("id", "user_id") - ) after_migrate_state_access_roles = list( CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") ) - # Before the rollback, we should only have the 1 invalid role entry - # since we set delete_after_migration to True in the migration. - self.assertEqual(len(original_state_access_roles), 1) - - # All original entries + 3 users * 4 roles = 12 - # plus the original invalid entry = 1 + 12 = 13 total entries + # 3 users * 4 roles = 12 recreated entries, plus the original invalid entry = 13 total self.assertEqual(len(after_migrate_state_access_roles), 1 + 12) - # Sanity check to ensure we have the expected number of UserSubjects related to - # the course permissions before migration (3 users * 4 roles = 12) - self.assertEqual(len(original_state_user_subjects), 12) - - # After rollback, we should have 0 UserSubjects related to the course permissions - self.assertEqual(len(state_after_migration_user_subjects), 0) - @patch("openedx_authz.api.data.CourseOverview", CourseOverview) def test_migrate_authz_to_legacy_course_roles_with_no_org_and_courses(self): # Migrate from legacy CourseAccessRole to new Casbin-based model @@ -927,7 +788,7 @@ def test_authz_migrate_course_authoring_command(self, mock_migrate): call_command("authz_migrate_course_authoring", "--course-id-list", self.course_id) mock_migrate.assert_called_once() - args, kwargs = mock_migrate.call_args + _, kwargs = mock_migrate.call_args self.assertEqual(kwargs["delete_after_migration"], False) @@ -938,7 +799,7 @@ def test_authz_migrate_course_authoring_command(self, mock_migrate): call_command("authz_migrate_course_authoring", "--delete", "--course-id-list", self.course_id) mock_migrate.assert_called_once() - args, kwargs = mock_migrate.call_args + _, kwargs = mock_migrate.call_args self.assertEqual(kwargs["delete_after_migration"], True) @@ -1001,7 +862,7 @@ def test_authz_rollback_course_authoring_command(self, mock_rollback): call_command("authz_rollback_course_authoring", "--course-id-list", self.course_id) mock_rollback.assert_called_once() - args, kwargs = mock_rollback.call_args + _, kwargs = mock_rollback.call_args self.assertEqual(kwargs["delete_after_migration"], False) From dea8ef95582e23612a3fabc50ecd27bf9cb2a471 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 7 Apr 2026 17:20:04 +0200 Subject: [PATCH 03/12] refactor: go back to previous tests --- openedx_authz/tests/test_migrations.py | 38 ++++++++++++++++++-------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/openedx_authz/tests/test_migrations.py b/openedx_authz/tests/test_migrations.py index d3dcfbf7..1e423942 100644 --- a/openedx_authz/tests/test_migrations.py +++ b/openedx_authz/tests/test_migrations.py @@ -295,16 +295,16 @@ class MockQuerySet: def __init__(self, permissions): self.permissions = permissions - def filter(self, **_): + def filter(self, **kwargs): return self - def select_related(self, *_, **__): + def select_related(self, *args, **kwargs): return self def all(self): return self.permissions - def get_or_create(self, **_): + def get_or_create(self): raise Exception("Unexpected error mock") class MockCourseAccessRole: @@ -444,7 +444,8 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_no_deletion(self): CourseAccessRole, UserSubject, course_id_list=course_id_list, org_id=None, delete_after_migration=False ) - # Casbin assignments are intact since delete_after_migration is False + # Check that each user has the expected legacy role after rollback and that errors + # are logged for any permissions that could not be rolled back for user in self.admin_users: assignments = get_user_role_assignments_in_scope( user_external_key=user.username, scope_external_key=self.course_id @@ -558,7 +559,8 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_with_deletion(self): CourseAccessRole, UserSubject, course_id_list=course_id_list, org_id=None, delete_after_migration=True ) - # Casbin assignments are removed since delete_after_migration is True + # Check that each user has the expected legacy role after rollback + # and that errors are logged for any permissions that could not be rolled back for user in self.admin_users: assignments = get_user_role_assignments_in_scope( user_external_key=user.username, scope_external_key=self.course_id @@ -587,7 +589,8 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_with_deletion(self): CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") ) - # 3 users * 4 roles = 12 recreated entries, plus the original invalid entry = 13 total + # All original entries + 3 users * 4 roles = 12 + # plus the original invalid entry = 1 + 12 = 13 total entries self.assertEqual(len(after_migrate_state_access_roles), 13) @patch("openedx_authz.api.data.CourseOverview", CourseOverview) @@ -617,7 +620,8 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_with_no_new_role_equi CourseAccessRole, UserSubject, course_id_list=course_id_list, org_id=None, delete_after_migration=True ) - # Admin assignments are removed; the rest remain since they had no legacy equivalent + # Check that each user has the expected legacy role after rollback + # and that errors are logged for any permissions that could not be rolled back for user in self.admin_users: assignments = get_user_role_assignments_in_scope( user_external_key=user.username, scope_external_key=self.course_id @@ -627,16 +631,22 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_with_no_new_role_equi assignments = get_user_role_assignments_in_scope( user_external_key=user.username, scope_external_key=self.course_id ) + # Since we are mocking the COURSE_ROLE_EQUIVALENCES mapping to only include a mapping for COURSE_ADMIN, + # the staff role will not have a legacy role equivalent and therefore should not be migrated back self.assertEqual(len(assignments), 1) for user in self.limited_staff: assignments = get_user_role_assignments_in_scope( user_external_key=user.username, scope_external_key=self.course_id ) + # Since we are mocking the COURSE_ROLE_EQUIVALENCES mapping to only include a mapping for COURSE_ADMIN, + # the limited_staff role will not have a legacy role equivalent and therefore should not be migrated back self.assertEqual(len(assignments), 1) for user in self.data_researcher: assignments = get_user_role_assignments_in_scope( user_external_key=user.username, scope_external_key=self.course_id ) + # Since we are mocking the COURSE_ROLE_EQUIVALENCES mapping to only include a mapping for COURSE_ADMIN, + # the data_researcher role will not have a legacy role equivalent and therefore should not be migrated back self.assertEqual(len(assignments), 1) # 3 staff + 3 limited_staff + 3 data_researcher = 9 entries with no legacy role equivalent @@ -646,7 +656,7 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_with_no_new_role_equi CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") ) - # 1 original invalid entry + 3 admin users rolled back = 4 total + # All original entries (1) + 3 users * 1 roles = 4 self.assertEqual(len(after_migrate_state_access_roles), 1 + 3) @patch("openedx_authz.api.data.CourseOverview", CourseOverview) @@ -698,13 +708,16 @@ def test_migrate_legacy_course_roles_to_authz_using_org_id(self): # Only the invalid role entry should remain since we set delete_after_migration to True self.assertEqual(len(after_migrate_state_access_roles), 1) + # Must be different before and after migration since we set delete_after_migration + # to True and we are deleting all # Now let's rollback permissions_with_errors, permissions_with_no_errors = migrate_authz_to_legacy_course_roles( CourseAccessRole, UserSubject, course_id_list=None, org_id=self.org, delete_after_migration=True ) - # Casbin assignments are removed since delete_after_migration is True + # Check that each user has the expected legacy role after rollback + # and that errors are logged for any permissions that could not be rolled back for user in self.admin_users: assignments = get_user_role_assignments_in_scope( user_external_key=user.username, scope_external_key=self.course_id @@ -733,7 +746,8 @@ def test_migrate_legacy_course_roles_to_authz_using_org_id(self): CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") ) - # 3 users * 4 roles = 12 recreated entries, plus the original invalid entry = 13 total + # All original entries + 3 users * 4 roles = 12 + # plus the original invalid entry = 1 + 12 = 13 total entries self.assertEqual(len(after_migrate_state_access_roles), 1 + 12) @patch("openedx_authz.api.data.CourseOverview", CourseOverview) @@ -788,7 +802,7 @@ def test_authz_migrate_course_authoring_command(self, mock_migrate): call_command("authz_migrate_course_authoring", "--course-id-list", self.course_id) mock_migrate.assert_called_once() - _, kwargs = mock_migrate.call_args + args, kwargs = mock_migrate.call_args self.assertEqual(kwargs["delete_after_migration"], False) @@ -799,7 +813,7 @@ def test_authz_migrate_course_authoring_command(self, mock_migrate): call_command("authz_migrate_course_authoring", "--delete", "--course-id-list", self.course_id) mock_migrate.assert_called_once() - _, kwargs = mock_migrate.call_args + args, kwargs = mock_migrate.call_args self.assertEqual(kwargs["delete_after_migration"], True) From 5cbfcdff390d6d912da2e67255e8609a72ebabc9 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 7 Apr 2026 18:18:03 +0200 Subject: [PATCH 04/12] refactor: consider course_id when migrating backward for course-level roles --- openedx_authz/engine/utils.py | 3 +-- openedx_authz/tests/test_migrations.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py index 211894f9..86060150 100644 --- a/openedx_authz/engine/utils.py +++ b/openedx_authz/engine/utils.py @@ -360,9 +360,8 @@ def migrate_authz_to_legacy_course_roles( "role": COURSE_ROLE_EQUIVALENCES[role_external_key], } - # Here we prioritize course_id over org for scope since course-level scope is more specific - # and also both are not needed to create a valid CourseAccessRole entry if isinstance(role_assignment.scope, CourseOverviewData): + course_access_role_kwargs["org"] = role_assignment.scope.org course_access_role_kwargs["course_id"] = scope_external_key elif isinstance(role_assignment.scope, OrgCourseOverviewGlobData): course_access_role_kwargs["org"] = role_assignment.scope.org diff --git a/openedx_authz/tests/test_migrations.py b/openedx_authz/tests/test_migrations.py index 1e423942..4c3a6687 100644 --- a/openedx_authz/tests/test_migrations.py +++ b/openedx_authz/tests/test_migrations.py @@ -876,7 +876,7 @@ def test_authz_rollback_course_authoring_command(self, mock_rollback): call_command("authz_rollback_course_authoring", "--course-id-list", self.course_id) mock_rollback.assert_called_once() - _, kwargs = mock_rollback.call_args + args, kwargs = mock_rollback.call_args self.assertEqual(kwargs["delete_after_migration"], False) From 7de1ed72bcc78828883b1c1882b14fcb9514283c Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Wed, 8 Apr 2026 10:45:14 +0200 Subject: [PATCH 05/12] test: include test cases for org-level migrations --- openedx_authz/engine/utils.py | 6 ++- openedx_authz/tests/test_migrations.py | 58 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py index 86060150..b0e86c0c 100644 --- a/openedx_authz/engine/utils.py +++ b/openedx_authz/engine/utils.py @@ -350,6 +350,7 @@ def migrate_authz_to_legacy_course_roles( for role_assignment in role_assignments: # Per valid role assignment, create corresponding CourseAccessRole entry + # depending on whether the scope is course-level or org-level glob try: user_external_key = role_assignment.subject.external_key role_external_key = role_assignment.roles[0].external_key @@ -367,12 +368,13 @@ def migrate_authz_to_legacy_course_roles( course_access_role_kwargs["org"] = role_assignment.scope.org else: logger.error( - f"Unexpected scope type: {type(role_assignment.scope)} for RoleAssignment with scope: {scope_external_key}" + f"Unexpected scope type: {type(role_assignment.scope)} for RoleAssignment with " + f"scope: {scope_external_key}, user: {user_external_key} and role: {role_external_key}, skipping." ) roles_with_errors.append(role_assignment) continue - course_access_role_model.objects.create(**course_access_role_kwargs) + course_access_role_model.objects.get_or_create(**course_access_role_kwargs) roles_with_no_errors.append(role_assignment) logger.info( diff --git a/openedx_authz/tests/test_migrations.py b/openedx_authz/tests/test_migrations.py index 4c3a6687..1d47ca38 100644 --- a/openedx_authz/tests/test_migrations.py +++ b/openedx_authz/tests/test_migrations.py @@ -7,6 +7,7 @@ from django.core.management import CommandError, call_command from django.test import TestCase +from openedx_authz.api.data import OrgCourseOverviewGlobData from openedx_authz.api.users import batch_unassign_role_from_users, get_user_role_assignments_in_scope from openedx_authz.constants.roles import ( COURSE_ADMIN, @@ -1114,3 +1115,60 @@ def test_migrate_authz_to_legacy_course_roles_with_library_env(self): self.assertEqual(len(errors), 0) self.assertEqual(len(successes), 12) + + @patch("openedx_authz.api.data.CourseOverview", CourseOverview) + def test_migrate_org_level_scope_creates_org_glob_assignment(self): + """A CourseAccessRole with org set and blank course_id maps to an OrgCourseOverviewGlobData scope. + + Expected result: + User has a COURSE_ADMIN assignment under the org-level glob scope. + """ + org_short_name_new = f"{OBJECT_PREFIX}org2" + Organization.objects.create(name=f"{OBJECT_PREFIX}org2_full", short_name=org_short_name_new) + user = User.objects.create_user( + username=f"org_user_{OBJECT_PREFIX}", email=f"org_user_{OBJECT_PREFIX}@example.com" + ) + CourseAccessRole.objects.create(user=user, org=org_short_name_new, course_id="", role="instructor") + + _, _ = migrate_legacy_course_roles_to_authz( + CourseAccessRole, course_id_list=None, org_id=org_short_name_new, delete_after_migration=True + ) + AuthzEnforcer.get_enforcer().load_policy() + + org_scope = OrgCourseOverviewGlobData.build_external_key(org_short_name_new) + assignments = get_user_role_assignments_in_scope( + user_external_key=user.username, scope_external_key=org_scope + ) + self.assertEqual(len(assignments), 1) + self.assertEqual(assignments[0].roles[0], COURSE_ADMIN) + + @patch("openedx_authz.api.data.CourseOverview", CourseOverview) + def test_rollback_org_level_scope_creates_org_only_course_access_role(self): + """Rollback of an OrgCourseOverviewGlobData assignment recreates a CourseAccessRole with org only. + + Expected result: + The recreated entry has org set and course_id is None (org-wide, not course-specific). + """ + org_short_name_new = f"{OBJECT_PREFIX}org2" + Organization.objects.create(name=f"{OBJECT_PREFIX}org2_full", short_name=org_short_name_new) + user = User.objects.create_user( + username=f"org_user_{OBJECT_PREFIX}", email=f"org_user_{OBJECT_PREFIX}@example.com" + ) + CourseAccessRole.objects.create(user=user, org=org_short_name_new, course_id="", role="instructor") + + migrate_legacy_course_roles_to_authz( + CourseAccessRole, course_id_list=None, org_id=org_short_name_new, delete_after_migration=True + ) + AuthzEnforcer.get_enforcer().load_policy() + + errors, successes = migrate_authz_to_legacy_course_roles( + CourseAccessRole, UserSubject, course_id_list=None, org_id=org_short_name_new, delete_after_migration=True + ) + + self.assertEqual(len(errors), 0) + self.assertEqual(len(successes), 1) + + recreated = CourseAccessRole.objects.filter(user=user, org=org_short_name_new).first() + self.assertIsNotNone(recreated) + self.assertEqual(recreated.org, org_short_name_new) + self.assertIsNone(recreated.course_id) From 7af3af94e6ee539d5ba4f407d71e0d850dcf2be4 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Wed, 8 Apr 2026 10:57:10 +0200 Subject: [PATCH 06/12] refactor: revert to previous tests that MUST pass with latest changes if compatible --- openedx_authz/tests/test_migrations.py | 127 ++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 1 deletion(-) diff --git a/openedx_authz/tests/test_migrations.py b/openedx_authz/tests/test_migrations.py index 1d47ca38..fd22fe67 100644 --- a/openedx_authz/tests/test_migrations.py +++ b/openedx_authz/tests/test_migrations.py @@ -441,6 +441,17 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_no_deletion(self): # Now let's rollback + # Capture the state of permissions before rollback to verify that rollback restores the original state + original_state_user_subjects = list( + UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) + .distinct() + .order_by("id") + .values("id", "user_id") + ) + original_state_access_roles = list( + CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") + ) + permissions_with_errors, permissions_with_no_errors = migrate_authz_to_legacy_course_roles( CourseAccessRole, UserSubject, course_id_list=course_id_list, org_id=None, delete_after_migration=False ) @@ -475,6 +486,34 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_no_deletion(self): self.assertEqual(len(permissions_with_errors), 0) self.assertEqual(len(permissions_with_no_errors), 12) # 3 users for each of the 4 roles = 12 total entries + state_after_migration_user_subjects = list( + UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) + .distinct() + .order_by("id") + .values("id", "user_id") + ) + after_migrate_state_access_roles = list( + CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") + ) + + # The number of CourseAccessRole entries should be the same as the original state + # since we are not deleting any entries in this test. + self.assertEqual(len(original_state_access_roles), 13) + + # All original entries should still be there since we are not deleting any entries + # and when creating new entries for the users that were migrated back to legacy roles, + # we are creating them with get_or_create which will not create duplicates if an entry + # with the same user, org, course_id and role already exists. + self.assertEqual(len(after_migrate_state_access_roles), 13) + + # Sanity check to ensure we have the expected number of UserSubjects related to + # the course permissions before migration (3 users * 4 roles = 12) + self.assertEqual(len(original_state_user_subjects), 12) + + # After rollback, we should have the same 12 UserSubjects related to the course permissions + # since we are not deleting any entries in this test, + self.assertEqual(len(state_after_migration_user_subjects), 12) + @patch("openedx_authz.api.data.CourseOverview", CourseOverview) def test_migrate_legacy_course_roles_to_authz_and_rollback_with_deletion(self): """Test the migration of legacy permissions from CourseAccessRole to @@ -556,6 +595,17 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_with_deletion(self): # Now let's rollback + # Capture the state of permissions before rollback to verify that rollback restores the original state + original_state_user_subjects = list( + UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) + .distinct() + .order_by("id") + .values("id", "user_id") + ) + original_state_access_roles = list( + CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") + ) + permissions_with_errors, permissions_with_no_errors = migrate_authz_to_legacy_course_roles( CourseAccessRole, UserSubject, course_id_list=course_id_list, org_id=None, delete_after_migration=True ) @@ -586,13 +636,30 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_with_deletion(self): self.assertEqual(len(permissions_with_errors), 0) self.assertEqual(len(permissions_with_no_errors), 12) + state_after_migration_user_subjects = list( + UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) + .distinct() + .order_by("id") + .values("id", "user_id") + ) after_migrate_state_access_roles = list( CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") ) + # Before the rollback, we should only have the 1 invalid role entry + # since we set delete_after_migration to True in the migration. + self.assertEqual(len(original_state_access_roles), 1) + # All original entries + 3 users * 4 roles = 12 # plus the original invalid entry = 1 + 12 = 13 total entries - self.assertEqual(len(after_migrate_state_access_roles), 13) + self.assertEqual(len(after_migrate_state_access_roles), 1 + 12) + + # Sanity check to ensure we have the expected number of UserSubjects related to + # the course permissions before migration (3 users * 4 roles = 12) + self.assertEqual(len(original_state_user_subjects), 12) + + # After rollback, we should have 0 UserSubjects related to the course permissions + self.assertEqual(len(state_after_migration_user_subjects), 0) @patch("openedx_authz.api.data.CourseOverview", CourseOverview) def test_migrate_legacy_course_roles_to_authz_and_rollback_with_no_new_role_equivalent(self): @@ -609,6 +676,17 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_with_no_new_role_equi # Now let's rollback + # Capture the state of permissions before rollback to verify that rollback restores the original state + original_state_user_subjects = list( + UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) + .distinct() + .order_by("id") + .values("id", "user_id") + ) + original_state_access_roles = list( + CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") + ) + # Mock the COURSE_ROLE_EQUIVALENCES mapping to only include a mapping # for COURSE_ADMIN to simulate the scenario where the staff, limited_staff # and data_researcher roles do not have a legacy role equivalent and @@ -653,13 +731,32 @@ def test_migrate_legacy_course_roles_to_authz_and_rollback_with_no_new_role_equi # 3 staff + 3 limited_staff + 3 data_researcher = 9 entries with no legacy role equivalent self.assertEqual(len(permissions_with_errors), 9) + state_after_migration_user_subjects = list( + UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) + .distinct() + .order_by("id") + .values("id", "user_id") + ) after_migrate_state_access_roles = list( CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") ) + # Before the rollback, we should only have the 1 invalid role entry + # since we set delete_after_migration to True in the migration. + self.assertEqual(len(original_state_access_roles), 1) + # All original entries (1) + 3 users * 1 roles = 4 self.assertEqual(len(after_migrate_state_access_roles), 1 + 3) + # Before the rollback, we should have the 12 UserSubjects related to the course permissions + # since we had 3 users with 4 roles each in the original state. + self.assertEqual(len(original_state_user_subjects), 12) + + # After rollback, we should have 9 UserSubjects related to the course permissions + # since the users with staff, limited_staff and data_researcher roles will not be + # migrated back to legacy roles due to our mocked COURSE_ROLE_EQUIVALENCES mapping. + self.assertEqual(len(state_after_migration_user_subjects), 9) + @patch("openedx_authz.api.data.CourseOverview", CourseOverview) def test_migrate_legacy_course_roles_to_authz_using_org_id(self): """Test the migration of legacy course roles to the new Casbin-based model @@ -713,6 +810,17 @@ def test_migrate_legacy_course_roles_to_authz_using_org_id(self): # to True and we are deleting all # Now let's rollback + # Capture the state of permissions before rollback to verify that rollback restores the original state + original_state_user_subjects = list( + UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) + .distinct() + .order_by("id") + .values("id", "user_id") + ) + original_state_access_roles = list( + CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") + ) + permissions_with_errors, permissions_with_no_errors = migrate_authz_to_legacy_course_roles( CourseAccessRole, UserSubject, course_id_list=None, org_id=self.org, delete_after_migration=True ) @@ -743,14 +851,31 @@ def test_migrate_legacy_course_roles_to_authz_using_org_id(self): self.assertEqual(len(permissions_with_errors), 0) self.assertEqual(len(permissions_with_no_errors), 12) + state_after_migration_user_subjects = list( + UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False) + .distinct() + .order_by("id") + .values("id", "user_id") + ) after_migrate_state_access_roles = list( CourseAccessRole.objects.all().order_by("id").values("id", "user_id", "org", "course_id", "role") ) + # Before the rollback, we should only have the 1 invalid role entry + # since we set delete_after_migration to True in the migration. + self.assertEqual(len(original_state_access_roles), 1) + # All original entries + 3 users * 4 roles = 12 # plus the original invalid entry = 1 + 12 = 13 total entries self.assertEqual(len(after_migrate_state_access_roles), 1 + 12) + # Sanity check to ensure we have the expected number of UserSubjects related to + # the course permissions before migration (3 users * 4 roles = 12) + self.assertEqual(len(original_state_user_subjects), 12) + + # After rollback, we should have 0 UserSubjects related to the course permissions + self.assertEqual(len(state_after_migration_user_subjects), 0) + @patch("openedx_authz.api.data.CourseOverview", CourseOverview) def test_migrate_authz_to_legacy_course_roles_with_no_org_and_courses(self): # Migrate from legacy CourseAccessRole to new Casbin-based model From 3c637f833d7781697b5457795d4b3480dcd2205f Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Wed, 8 Apr 2026 11:25:53 +0200 Subject: [PATCH 07/12] refactor: address quality issues --- openedx_authz/engine/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py index b0e86c0c..28f9547c 100644 --- a/openedx_authz/engine/utils.py +++ b/openedx_authz/engine/utils.py @@ -305,8 +305,7 @@ def migrate_authz_to_legacy_course_roles( To build each CourseAccessRole entry, the function needs: - A user: resolved from role assignments in scopes linked to courses. - - A scope: either a CourseOverviewData (course-level) or OrgCourseOverviewGlobData (org-level glob), - filtered by course_id or org_id if provided. + - A scope: a CourseOverviewData or OrgCourseOverviewGlobData instance, optionally filtered by course_id or org_id. - A role: a role external key that maps to a legacy role in COURSE_ROLE_EQUIVALENCES. param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function From 4a84f7cb1206a3b9081fd9bf97b05e3997ff66dc Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Wed, 8 Apr 2026 17:12:56 +0200 Subject: [PATCH 08/12] fix: address quality errors --- openedx_authz/engine/utils.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py index 28f9547c..d8966688 100644 --- a/openedx_authz/engine/utils.py +++ b/openedx_authz/engine/utils.py @@ -324,9 +324,8 @@ def migrate_authz_to_legacy_course_roles( role_assignments = get_all_role_assignments_per_scope_type(scope_type=CourseOverviewData) # Two cases here: - # 1. If org_id is provided, we filter by org_id which will include both org-level glob scopes and course-level scopes linked to that org - # 2. If only course_id_list is provided, we filter by course_id which will include only course-level scopes linked to those course_ids since - # org-level glob scopes don't have course_id in their scope object + # 1. org_id provided: filter by org — includes org-level glob and course-level scopes for that org. + # 2. only course_id_list provided: filter by course_id — org-level glob scopes are excluded (no course_id). if org_id: role_assignments = [ role_assignment @@ -385,7 +384,7 @@ def migrate_authz_to_legacy_course_roles( if delete_after_migration: unassignments[(role_external_key, scope_external_key)].append(user_external_key) - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught logger.error( f"Error rolling back RoleAssignment for User: {role_assignment.subject.external_key} " f"in Role: {role_assignment.roles[0].external_key} and Scope: {role_assignment.scope.external_key}: {e}" From ed7f09ee98b61fa3e6015727a8454966874500c0 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Wed, 8 Apr 2026 17:55:21 +0200 Subject: [PATCH 09/12] refactor: index users to avoid additional queries & improve inline comments --- openedx_authz/engine/utils.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py index d8966688..3cef6e22 100644 --- a/openedx_authz/engine/utils.py +++ b/openedx_authz/engine/utils.py @@ -319,8 +319,10 @@ def migrate_authz_to_legacy_course_roles( """ _validate_migration_input(course_id_list, org_id) - # CourseOverviewData and OrgCourseOverviewGlobData share the same namespace, - # so filtering by CourseOverviewData captures both course-level and org-level glob assignments. + # CourseOverviewData and OrgCourseOverviewGlobData share the same NAMESPACE ("course-v1"), + # and get_all_role_assignments_per_scope_type matches by NAMESPACE. Passing CourseOverviewData + # therefore captures both course-level and org-level glob assignments. The exact scope type + # is narrowed per-assignment via isinstance checks in the loop below. role_assignments = get_all_role_assignments_per_scope_type(scope_type=CourseOverviewData) # Two cases here: @@ -345,6 +347,14 @@ def migrate_authz_to_legacy_course_roles( roles_with_no_errors = [] unassignments = defaultdict(list) + user_external_keys = {assignment.subject.external_key for assignment in role_assignments} + users_by_username = { + subject.user.username: subject.user + for subject in user_subject_model.objects.filter( + user__username__in=user_external_keys + ).select_related("user") + } + for role_assignment in role_assignments: # Per valid role assignment, create corresponding CourseAccessRole entry @@ -355,7 +365,7 @@ def migrate_authz_to_legacy_course_roles( scope_external_key = role_assignment.scope.external_key course_access_role_kwargs = { - "user": user_subject_model.objects.get(user__username=user_external_key).user, + "user": users_by_username[user_external_key], "role": COURSE_ROLE_EQUIVALENCES[role_external_key], } @@ -365,6 +375,8 @@ def migrate_authz_to_legacy_course_roles( elif isinstance(role_assignment.scope, OrgCourseOverviewGlobData): course_access_role_kwargs["org"] = role_assignment.scope.org else: + # This would only happen for course roles assigned instance-wide + # which is not yet supported logger.error( f"Unexpected scope type: {type(role_assignment.scope)} for RoleAssignment with " f"scope: {scope_external_key}, user: {user_external_key} and role: {role_external_key}, skipping." From d43100b4bc19471546cd3ae1b8ca7593c3ebcf14 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Thu, 9 Apr 2026 15:11:36 +0200 Subject: [PATCH 10/12] refactor: address PR reviews --- openedx_authz/engine/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py index 3cef6e22..3b0df497 100644 --- a/openedx_authz/engine/utils.py +++ b/openedx_authz/engine/utils.py @@ -339,8 +339,7 @@ def migrate_authz_to_legacy_course_roles( role_assignments = [ role_assignment for role_assignment in role_assignments - if isinstance(role_assignment.scope, CourseOverviewData) - and role_assignment.scope.external_key in course_id_list + if role_assignment.scope.course_id in course_id_list ] roles_with_errors = [] From 61e318d7674e64b73d59fb4f7b28d04a56b58e5c Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Thu, 9 Apr 2026 15:37:44 +0200 Subject: [PATCH 11/12] refactor: use is instance instead of matching per namespace --- openedx_authz/api/roles.py | 11 ++++++----- openedx_authz/engine/utils.py | 8 +++----- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/openedx_authz/api/roles.py b/openedx_authz/api/roles.py index 489fb52c..f56ee8e6 100644 --- a/openedx_authz/api/roles.py +++ b/openedx_authz/api/roles.py @@ -556,20 +556,21 @@ def unassign_subject_from_all_roles(subject: SubjectData) -> bool: return enforcer.remove_filtered_grouping_policy(GroupingPolicyIndex.SUBJECT.value, subject.namespaced_key) -def get_all_role_assignments_per_scope_type(scope_type: type[ScopeData]) -> list[RoleAssignmentData]: - """Get all role assignments for a specific scope type. +def get_all_role_assignments_per_scope_type(scope_types: list[type[ScopeData]]) -> list[RoleAssignmentData]: + """Get all role assignments matching any of the given scope types. Loads all grouping policies from the enforcer and filters in Python. Casbin policies store full scope keys (e.g., 'course-v1^course-v1:Org+Course+Run'), so there is no way to query by scope type directly so the filtering must happen here. Args: - scope_type: A ScopeData subclass (not an instance) used to match by NAMESPACE. + scope_types: A list of ScopeData subclasses (not instances). Assignments matching + any of the given types are returned. Returns: - list[RoleAssignmentData]: All assignments whose scope matches the given scope type. + list[RoleAssignmentData]: All assignments whose scope is an instance of any of the given scope types. """ return [ role_assignment for role_assignment in get_role_assignments() - if role_assignment.scope.NAMESPACE == scope_type.NAMESPACE + if isinstance(role_assignment.scope, tuple(scope_types)) ] diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py index 3b0df497..26f3e98e 100644 --- a/openedx_authz/engine/utils.py +++ b/openedx_authz/engine/utils.py @@ -319,11 +319,9 @@ def migrate_authz_to_legacy_course_roles( """ _validate_migration_input(course_id_list, org_id) - # CourseOverviewData and OrgCourseOverviewGlobData share the same NAMESPACE ("course-v1"), - # and get_all_role_assignments_per_scope_type matches by NAMESPACE. Passing CourseOverviewData - # therefore captures both course-level and org-level glob assignments. The exact scope type - # is narrowed per-assignment via isinstance checks in the loop below. - role_assignments = get_all_role_assignments_per_scope_type(scope_type=CourseOverviewData) + role_assignments = get_all_role_assignments_per_scope_type( + scope_types=[CourseOverviewData, OrgCourseOverviewGlobData] + ) # Two cases here: # 1. org_id provided: filter by org — includes org-level glob and course-level scopes for that org. From 4727f2c222060a67553e4762f7022414a7ef9888 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 1 Apr 2026 18:26:13 -0500 Subject: [PATCH 12/12] feat: add course authoring automatic migration mechanism --- openedx_authz/admin.py | 11 +- openedx_authz/engine/utils.py | 414 +++++++++++++----- openedx_authz/handlers.py | 115 ++++- .../0008_authzcourseauthoringmigrationrun.py | 82 ++++ openedx_authz/models/__init__.py | 1 + openedx_authz/models/migrations.py | 130 ++++++ openedx_authz/settings/common.py | 5 + openedx_authz/settings/test.py | 3 + openedx_authz/tasks.py | 66 +++ requirements/base.in | 1 + requirements/base.txt | 46 +- requirements/dev.txt | 60 ++- requirements/doc.txt | 60 ++- requirements/pip-tools.txt | 2 +- requirements/quality.txt | 60 ++- requirements/test.txt | 60 ++- 16 files changed, 1002 insertions(+), 114 deletions(-) create mode 100644 openedx_authz/migrations/0008_authzcourseauthoringmigrationrun.py create mode 100644 openedx_authz/models/migrations.py create mode 100644 openedx_authz/tasks.py diff --git a/openedx_authz/admin.py b/openedx_authz/admin.py index e2285158..c77ebdff 100644 --- a/openedx_authz/admin.py +++ b/openedx_authz/admin.py @@ -4,7 +4,7 @@ from django import forms from django.contrib import admin -from openedx_authz.models import ExtendedCasbinRule +from openedx_authz.models import AuthzCourseAuthoringMigrationRun, ExtendedCasbinRule class CasbinRuleForm(forms.ModelForm): @@ -48,3 +48,12 @@ class CasbinRuleAdmin(admin.ModelAdmin): # TODO: In a future, possibly we should only show an inline for the rules that # have an extended rule, and show the subject and scope information in detail. inlines = [ExtendedCasbinRuleInline] + + +@admin.register(AuthzCourseAuthoringMigrationRun) +class AuthzCourseAuthoringMigrationRunAdmin(admin.ModelAdmin): + """Admin for AuthzCourseAuthoringMigrationRun to display additional metadata.""" + + list_display = ("id", "scope_type", "scope_key", "migration_type", "status", "created_at", "updated_at") + search_fields = ("scope_type", "scope_key", "migration_type", "status") + list_filter = ("scope_type", "migration_type", "status") diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py index 26f3e98e..c5f4a3c3 100644 --- a/openedx_authz/engine/utils.py +++ b/openedx_authz/engine/utils.py @@ -6,31 +6,76 @@ import logging from collections import defaultdict +from collections.abc import Callable +from typing import Any from casbin import Enforcer +from django.contrib.auth.models import AbstractUser +from django.core.cache import cache -from openedx_authz.api.data import CourseOverviewData, OrgCourseOverviewGlobData +from openedx_authz.api.data import CourseOverviewData, OrgCourseOverviewGlobData, RoleAssignmentData from openedx_authz.api.roles import get_all_role_assignments_per_scope_type from openedx_authz.api.users import ( assign_role_to_user_in_scope, batch_assign_role_to_users_in_scope, batch_unassign_role_from_users, ) -from openedx_authz.constants.roles import ( - LEGACY_COURSE_ROLE_EQUIVALENCES, - LIBRARY_ADMIN, - LIBRARY_AUTHOR, - LIBRARY_USER, -) +from openedx_authz.constants.roles import LEGACY_COURSE_ROLE_EQUIVALENCES, LIBRARY_ADMIN, LIBRARY_AUTHOR, LIBRARY_USER +from openedx_authz.models.migrations import AuthzCourseAuthoringMigrationRun, MigrationType, ScopeType logger = logging.getLogger(__name__) -GROUPING_POLICY_PTYPES = ["g", "g2", "g3", "g4", "g5", "g6"] +GROUPING_POLICY_PTYPES = ["g", "g2", "g3", "g4", "g5", "g6"] # Map new roles back to legacy roles for rollback purposes COURSE_ROLE_EQUIVALENCES = {v: k for k, v in LEGACY_COURSE_ROLE_EQUIVALENCES.items()} +MIGRATION_LOCK_TIMEOUT = 60 * 60 # 1 hour + + +def _get_lock_key(scope_type: ScopeType, scope_key: str) -> str: + """Generate a cache key for migration locking. + + Args: + scope_type (ScopeType): Type of scope ('course' or 'org') + scope_key (str): Identifier for the scope + + Returns: + str: Cache key for the migration run lock + """ + return f"authz_migration_lock:{scope_type}:{scope_key}" + + +def _acquire_lock(scope_type: ScopeType, scope_key: str, migration_run_id: int) -> bool: + """Acquire a lock for migration to prevent concurrent migrations. + + cache.add() returns True only if the key did not exist (lock acquired). + If it returns False, another migration run is already in progress. + + Args: + scope_type (ScopeType): Type of scope ('course' or 'org') + scope_key (str): Identifier for the scope + migration_run_id (int): Unique migration run identifier + timeout (int): Lock timeout in seconds + + Returns: + bool: True if migration run lock was acquired, False otherwise + """ + lock_key = _get_lock_key(scope_type, scope_key) + return cache.add(lock_key, migration_run_id, MIGRATION_LOCK_TIMEOUT) + + +def _release_lock(scope_type: ScopeType, scope_key: str) -> None: + """Release a migration lock. + + Args: + scope_type (ScopeType): Type of scope ('course' or 'org') + scope_key (str): Identifier for the scope + """ + lock_key = _get_lock_key(scope_type, scope_key) + cache.delete(lock_key) + def migrate_policy_between_enforcers( source_enforcer: Enforcer, @@ -185,65 +230,99 @@ def _validate_migration_input(course_id_list, org_id): ) -def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_list, org_id, delete_after_migration): +def _run_scoped_migration( + migration_type: MigrationType, + course_id_list: list[str] | None, + org_id: str | None, + delete_after_migration: bool, + process_scope_fn: Callable[[ScopeType, str], tuple[list, list]], +) -> tuple[list, list]: + """Orchestrate a migration over a set of scopes with per-scope locking and tracking. + + For each scope, creates a ``AuthzCourseAuthoringMigrationRun``, acquires a distributed lock, + and delegates the work to ``process_scope_fn``. Scopes whose lock cannot be acquired are + marked SKIPPED. + + Args: + migration_type (MigrationType): Direction of the migration (forward or rollback). + course_id_list (list[str] | None): List of course IDs to migrate individually. + Mutually exclusive with ``org_id``. + org_id (str | None): Organization ID to migrate as a single org-scoped run. + Mutually exclusive with ``course_id_list``. + delete_after_migration (bool): Whether to delete successfully migrated entries after migration. + process_scope_fn (Callable[[ScopeType, str], tuple[list, list]]): Callable that + receives ``(scope_type, scope_key)`` and returns a ``(errors, successes)`` + tuple of ``RoleAssignmentData`` lists. + + Returns: + A tuple of ``(errors, successes)`` aggregated across all processed scopes, where + each element is a list of ``RoleAssignmentData`` instances. """ - Migrate legacy course role data to the new Casbin-based authorization model. - This function reads legacy permissions from the CourseAccessRole model - and assigns equivalent roles in the new authorization system. + scopes_to_process = ( + [(ScopeType.COURSE, course_id) for course_id in course_id_list] if course_id_list else [(ScopeType.ORG, org_id)] + ) - The old Course permissions are stored in the CourseAccessRole model, it consists of the following columns: + all_errors = [] + all_successes = [] - - user: FK to User - - org: optional Organization string - - course_id: optional CourseKeyField of Course - - role: 'instructor' | 'staff' | 'limited_staff' | 'data_researcher' + for scope_type, scope_key in scopes_to_process: + metadata = { + "course_id": scope_key if scope_type == ScopeType.COURSE else None, + "org_id": org_id if scope_type == ScopeType.ORG else None, + "delete_after": delete_after_migration, + } + migration_run = AuthzCourseAuthoringMigrationRun.create_pending(migration_type, scope_type, scope_key, metadata) - In the new Authz model, this would roughly translate to: + if not _acquire_lock(scope_type, scope_key, migration_run.id): + logger.warning(f"Migration already in progress for {scope_type}:{scope_key}.") + migration_run.mark_skipped(reason="locked") + continue - - course_id: scope - - user: subject - - role: role + migration_run.mark_running() - The scope assigned per row depends on which fields are set: - - course_id set: course-level scope (e.g. "course-v1:OpenedX+CS101+2024"). - - course_id blank, org set: org-level glob scope (e.g. "course-v1:OpenedX+*"). - - both set: course_id takes precedence as the more specific scope. + errors, successes = process_scope_fn(scope_type, scope_key) + all_errors.extend(errors) + all_successes.extend(successes) - param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function - is intended to run within a Django migration context, where direct model imports can cause issues. - param course_id_list: Optional list of course IDs to filter the migration. - param org_id: Optional organization ID to filter the migration. - param delete_after_migration: Whether to delete successfully migrated legacy permissions after migration. - """ - _validate_migration_input(course_id_list, org_id) + migration_run.mark_completed(metadata_updates={"success_count": len(successes), "error_count": len(errors)}) + _release_lock(scope_type, scope_key) - course_access_role_filter = { - "course_id__startswith": "course-v1:", - } + return all_errors, all_successes - if org_id: - course_access_role_filter["org"] = org_id - if course_id_list and not org_id: - # Only filter by course_id if org_id is not provided, - # otherwise we will filter by org_id which is more efficient - course_access_role_filter["course_id__in"] = course_id_list +def _process_forward_permissions( + legacy_permissions: list, + course_access_role_model: Any, + delete_after_migration: bool, +) -> tuple[list, list]: + """Process a batch of legacy course role permissions and assign them in the Casbin-based model. - legacy_permissions = ( - course_access_role_model.objects.filter(**course_access_role_filter).select_related("user").all() - ) + For each permission, resolves the equivalent Casbin role, determines the scope + (course or org), and calls ``assign_role_to_user_in_scope``. Permissions with + unknown roles or missing scope data are collected as errors without raising. + If ``delete_after_migration`` is ``True``, successfully migrated records are + deleted from the legacy ``CourseAccessRole`` table. - # List to keep track of any permissions that could not be migrated + Args: + legacy_permissions (list): ``CourseAccessRole`` instances to migrate. + course_access_role_model (Any): The ``CourseAccessRole`` Django model class, + used to delete records after successful migration. + delete_after_migration (bool): If ``True``, deletes each successfully migrated + record from the legacy table. + + Returns: + tuple[list, list]: ``(errors, successes)`` — two lists of ``CourseAccessRole`` + instances. ``errors`` contains records that could not be migrated (unknown + role, missing scope, or failed assignment); ``successes`` contains those + that were migrated and, if requested, deleted. + """ permissions_with_errors = [] permissions_with_no_errors = [] for permission in legacy_permissions: - # Migrate the permission to the new model - role = LEGACY_COURSE_ROLE_EQUIVALENCES.get(permission.role) + if role is None: - # This should not happen as there are no more access_levels defined - # in CourseAccessRole, log and skip logger.error(f"Unknown access level: {permission.role} for User: {permission.user}") permissions_with_errors.append(permission) continue @@ -253,17 +332,14 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis elif permission.org: scope_external_key = OrgCourseOverviewGlobData.build_external_key(permission.org) else: - # This should not happen as either course_id or org should be defined for each permission, log and skip logger.error( f"Permission for User: {permission.user.username} has neither course_id nor org defined, skipping." ) permissions_with_errors.append(permission) continue - # Permission applied to individual user logger.info( - f"Migrating permission for User: {permission.user.username} " - f"to Role: {role} in Scope: {scope_external_key}" + f"Migrating permission for User: {permission.user.username} to Role: {role} in Scope: {scope_external_key}" ) is_user_added = assign_role_to_user_in_scope( @@ -275,8 +351,7 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis if not is_user_added: logger.error( f"Failed to migrate permission for User: {permission.user.username} " - f"to Role: {role} in Scope: {scope_external_key} " - "user may already have this permission assigned" + f"to Role: {role} in Scope: {scope_external_key}" ) permissions_with_errors.append(permission) continue @@ -284,7 +359,6 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis permissions_with_no_errors.append(permission) if delete_after_migration: - # Only delete permissions that were successfully migrated to avoid data loss. course_access_role_model.objects.filter(id__in=[p.id for p in permissions_with_no_errors]).delete() logger.info(f"Deleted {len(permissions_with_no_errors)} legacy permissions after successful migration.") logger.info(f"Retained {len(permissions_with_errors)} legacy permissions that had errors during migration.") @@ -292,70 +366,117 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis return permissions_with_errors, permissions_with_no_errors -def migrate_authz_to_legacy_course_roles( - course_access_role_model, user_subject_model, course_id_list, org_id, delete_after_migration -): +def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_list, org_id, delete_after_migration): """ - Migrate permissions from the new Casbin-based authorization model back to the legacy CourseAccessRole model. - This function reads permissions from the Casbin enforcer and creates equivalent entries in the - CourseAccessRole model. + Migrate legacy course role data to the new Casbin-based authorization model. + This function reads legacy permissions from the CourseAccessRole model + and assigns equivalent roles in the new authorization system. - This is essentially the reverse of migrate_legacy_course_roles_to_authz and is intended - for rollback purposes in case of migration issues. + The old Course permissions are stored in the CourseAccessRole model, it consists of the following columns: - To build each CourseAccessRole entry, the function needs: - - A user: resolved from role assignments in scopes linked to courses. - - A scope: a CourseOverviewData or OrgCourseOverviewGlobData instance, optionally filtered by course_id or org_id. - - A role: a role external key that maps to a legacy role in COURSE_ROLE_EQUIVALENCES. + - user: FK to User + - org: optional Organization string + - course_id: optional CourseKeyField of Course + - role: 'instructor' | 'staff' | 'limited_staff' | 'data_researcher' + + In the new Authz model, this would roughly translate to: + + - course_id: scope + - user: subject + - role: role + + The scope assigned per row depends on which fields are set: + - course_id set: course-level scope (e.g. "course-v1:OpenedX+CS101+2024"). + - course_id blank, org set: org-level glob scope (e.g. "course-v1:OpenedX+*"). + - both set: course_id takes precedence as the more specific scope. + + When course_id_list is provided, one MigrationRun record is created per course ID so that each + course is tracked, locked, and completed independently. When only org_id is provided, a single + org-scoped MigrationRun is created instead. param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function is intended to run within a Django migration context, where direct model imports can cause issues. - param user_subject_model: It should be the UserSubject model. This is passed in because the function - is intended to run within a Django migration context, where direct model imports can cause issues. param course_id_list: Optional list of course IDs to filter the migration. param org_id: Optional organization ID to filter the migration. - param delete_after_migration: Whether to unassign successfully migrated permissions - from the new model after migration. + param delete_after_migration: Whether to delete successfully migrated legacy permissions after migration. """ _validate_migration_input(course_id_list, org_id) - role_assignments = get_all_role_assignments_per_scope_type( - scope_types=[CourseOverviewData, OrgCourseOverviewGlobData] - ) + course_access_role_filter = {} - # Two cases here: - # 1. org_id provided: filter by org — includes org-level glob and course-level scopes for that org. - # 2. only course_id_list provided: filter by course_id — org-level glob scopes are excluded (no course_id). if org_id: - role_assignments = [ - role_assignment - for role_assignment in role_assignments - if role_assignment.scope.org == org_id - ] - - if course_id_list and not org_id: - role_assignments = [ - role_assignment - for role_assignment in role_assignments - if role_assignment.scope.course_id in course_id_list - ] + course_access_role_filter["org"] = org_id + elif course_id_list: + # Only filter by course_id if org_id is not provided, + # otherwise we will filter by org_id which is more efficient + course_access_role_filter["course_id__in"] = course_id_list + + legacy_permissions = list( + course_access_role_model.objects.filter(**course_access_role_filter).select_related("user").all() + ) + + def process_scope(scope_type: ScopeType, scope_key: str) -> tuple[list, list]: + """ + Select the appropriate permission slice for the given scope + and delegate to ``_process_forward_permissions``. + For course scopes, only permissions whose ``course_id`` matches ``scope_key`` are processed. + For org scopes, the full ``legacy_permissions`` list is used. + """ + if scope_type == ScopeType.COURSE: + scope_permissions = [perm for perm in legacy_permissions if str(perm.course_id) == scope_key] + else: + scope_permissions = legacy_permissions + return _process_forward_permissions( + legacy_permissions=scope_permissions, + course_access_role_model=course_access_role_model, + delete_after_migration=delete_after_migration, + ) + + return _run_scoped_migration( + migration_type=MigrationType.FORWARD, + course_id_list=course_id_list, + org_id=org_id, + delete_after_migration=delete_after_migration, + process_scope_fn=process_scope, + ) + + +def _process_rollback_assignments( + role_assignments: list[RoleAssignmentData], + users_by_username: dict[str, AbstractUser], + course_access_role_model: Any, + delete_after_migration: bool, +) -> tuple[list, list]: + """Recreate legacy CourseAccessRole entries from a list of openedx-authz role assignments. + + For each assignment, resolves the corresponding user and role, then calls ``get_or_create`` + on the legacy model to avoid duplicates. Course-scoped assignments populate both ``org`` + and ``course_id``; org-scoped assignments populate only ``org``. Any assignment with an + unsupported scope type is logged and collected as an error. + + If ``delete_after_migration`` is True, successfully migrated assignments are unassigned + from the new AuthZ model after all entries have been recreated. + + Args: + role_assignments: List of openedx-authz role assignments to roll back. + users_by_username: Mapping of username to User instance, used to resolve the + user FK for each CourseAccessRole entry. + course_access_role_model: The ``CourseAccessRole`` Django model. Passed explicitly + to avoid import issues in Django migration contexts. + delete_after_migration: If True, unassigns each successfully migrated assignment + from the new AuthZ model after recreating its legacy entry. + + Returns: + A tuple of ``(errors, successes)``, where each element is a list of + ``RoleAssignmentData`` instances. ``errors`` contains assignments that could not + be migrated; ``successes`` contains those that were migrated successfully. + """ roles_with_errors = [] roles_with_no_errors = [] unassignments = defaultdict(list) - user_external_keys = {assignment.subject.external_key for assignment in role_assignments} - users_by_username = { - subject.user.username: subject.user - for subject in user_subject_model.objects.filter( - user__username__in=user_external_keys - ).select_related("user") - } - for role_assignment in role_assignments: - - # Per valid role assignment, create corresponding CourseAccessRole entry - # depending on whether the scope is course-level or org-level glob try: user_external_key = role_assignment.subject.external_key role_external_key = role_assignment.roles[0].external_key @@ -400,8 +521,6 @@ def migrate_authz_to_legacy_course_roles( ) roles_with_errors.append(role_assignment) - # Once the loop is done, we can log summary of unassignments - # and perform batch unassignment if delete_after_migration is True if delete_after_migration: total_unassignments = sum(len(users) for users in unassignments.values()) logger.info(f"Total of {total_unassignments} role assignments unassigned after successful rollback migration.") @@ -417,3 +536,92 @@ def migrate_authz_to_legacy_course_roles( ) return roles_with_errors, roles_with_no_errors + + +def migrate_authz_to_legacy_course_roles( + course_access_role_model, user_subject_model, course_id_list, org_id, delete_after_migration +): + """ + Migrate permissions from the new Casbin-based authorization model back to the legacy CourseAccessRole model. + This function reads permissions from the Casbin enforcer and creates equivalent entries in the + CourseAccessRole model. + + This is essentially the reverse of migrate_legacy_course_roles_to_authz and is intended + for rollback purposes in case of migration issues. + + To build each CourseAccessRole entry, the function needs: + - A user: resolved from role assignments in scopes linked to courses. + - A scope: a CourseOverviewData or OrgCourseOverviewGlobData instance, optionally filtered by course_id or org_id. + - A role: a role external key that maps to a legacy role in COURSE_ROLE_EQUIVALENCES. + + When course_id_list is provided, one MigrationRun record is created per course ID so that each + course is tracked, locked, and completed independently. When only org_id is provided, a single + org-scoped MigrationRun is created instead. + + param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function + is intended to run within a Django migration context, where direct model imports can cause issues. + param user_subject_model: It should be the UserSubject model. This is passed in because the function + is intended to run within a Django migration context, where direct model imports can cause issues. + param course_id_list: Optional list of course IDs to filter the migration. + param org_id: Optional organization ID to filter the migration. + param delete_after_migration: Whether to unassign successfully migrated permissions + from the new model after migration. + """ + _validate_migration_input(course_id_list, org_id) + + role_assignments = get_all_role_assignments_per_scope_type( + scope_types=[CourseOverviewData, OrgCourseOverviewGlobData] + ) + + user_external_keys = set() + assignments_by_course = defaultdict(list) + filtered_assignments = [] + + for role_assignment in role_assignments: + # If org_id is provided, skip assignments that don't belong to the target org + # including org-level glob and course-level assignments + if org_id and role_assignment.scope.org != org_id: + continue + # Otherwise, keep the assignment + filtered_assignments.append(role_assignment) + + # collect usernames for the DB query below + user_external_keys.add(role_assignment.subject.external_key) + + # Only course-level assignments are grouped by course_id + if isinstance(role_assignment.scope, CourseOverviewData): + assignments_by_course[role_assignment.scope.course_id].append(role_assignment) + + users_by_username = { + subject.user.username: subject.user + for subject in user_subject_model.objects.filter( + user__username__in=user_external_keys, + ).select_related("user") + } + + def process_scope(scope_type: ScopeType, scope_key: str) -> tuple[list, list]: + """ + Select the appropriate assignment slice for the given scope + and delegate to ``_process_rollback_assignments``. + + For course scopes, only assignments belonging to that specific course are processed. + For org scopes, the full ``filtered_assignments`` list is used. + """ + if scope_type == ScopeType.COURSE: + scope_assignments = assignments_by_course[scope_key] + else: + scope_assignments = filtered_assignments + return _process_rollback_assignments( + role_assignments=scope_assignments, + users_by_username=users_by_username, + course_access_role_model=course_access_role_model, + delete_after_migration=delete_after_migration, + ) + + return _run_scoped_migration( + migration_type=MigrationType.ROLLBACK, + course_id_list=course_id_list, + org_id=org_id, + delete_after_migration=delete_after_migration, + process_scope_fn=process_scope, + ) diff --git a/openedx_authz/handlers.py b/openedx_authz/handlers.py index 7701123e..1bfd9097 100644 --- a/openedx_authz/handlers.py +++ b/openedx_authz/handlers.py @@ -7,19 +7,32 @@ import logging from casbin_adapter.models import CasbinRule -from django.db.models.signals import post_delete +from django.conf import settings +from django.db.models.signals import post_delete, pre_save from django.dispatch import receiver from openedx_authz.api.users import unassign_all_roles_from_user from openedx_authz.models.core import ExtendedCasbinRule +from openedx_authz.models.migrations import MigrationType, ScopeType +from openedx_authz.tasks import migrate_course_authoring_async try: from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL except ImportError: USER_RETIRE_LMS_CRITICAL = None +try: + from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel, WaffleFlagOrgOverrideModel +except ImportError: + WaffleFlagCourseOverrideModel = None + WaffleFlagOrgOverrideModel = None + + logger = logging.getLogger(__name__) +# Flag name to monitor for automatic migration +AUTHZ_COURSE_AUTHORING_FLAG = "authz.enable_course_authoring" + @receiver(post_delete, sender=ExtendedCasbinRule) def delete_casbin_rule_on_extended_rule_deletion(sender, instance, **kwargs): # pylint: disable=unused-argument @@ -82,3 +95,103 @@ def unassign_roles_on_user_retirement(sender, user, **kwargs): # pylint: disabl # Only register the handler if the signal is available (i.e., running in Open edX) if USER_RETIRE_LMS_CRITICAL is not None: USER_RETIRE_LMS_CRITICAL.connect(unassign_roles_on_user_retirement) + + +def trigger_course_authoring_migration( + instance: WaffleFlagCourseOverrideModel | WaffleFlagOrgOverrideModel, + scope_type: ScopeType, + scope_key: str, +) -> None: + """Trigger an asynchronous migration run. + + Args: + instance: The waffle flag instance that triggered the migration + scope_type (ScopeType): Type of scope being migrated: course or organization + scope_key (str): Course ID or organization name + """ + if instance.waffle_flag != AUTHZ_COURSE_AUTHORING_FLAG: + return + + last_flag_obj = None + if isinstance(instance, WaffleFlagCourseOverrideModel): + last_flag_obj = ( + WaffleFlagCourseOverrideModel.objects.filter(course_id=instance.course_id).order_by("-id").first() + ) + elif isinstance(instance, WaffleFlagOrgOverrideModel): + last_flag_obj = WaffleFlagOrgOverrideModel.objects.filter(org=instance.org).order_by("-id").first() + + if last_flag_obj and last_flag_obj.enabled == instance.enabled: + logger.info("No change in waffle flag, skipping course migration") + return + + if not instance.enabled: + migration_type = MigrationType.ROLLBACK + else: + migration_type = MigrationType.FORWARD + + course_id_list = None + org_id = None + + if scope_type == ScopeType.COURSE: + course_id_list = [scope_key] + elif scope_type == ScopeType.ORG: + org_id = scope_key + + logger.info(f"Triggering {migration_type} migration for {scope_type}:{scope_key} due to waffle flag change") + + migrate_course_authoring_async( + migration_type=migration_type, + scope_type=scope_type, + scope_key=scope_key, + course_id_list=course_id_list, + org_id=org_id, + delete_after=True, + ) + + +@receiver(pre_save, sender=WaffleFlagCourseOverrideModel) +def handle_course_waffle_flag_change(sender, instance, **kwargs) -> None: # pylint: disable=unused-argument + """Handle changes to course-level waffle flags. + + When the authz.enable_course_authoring flag is changed for a course, + trigger the appropriate migration run. Only trigger if automatic migration + is enabled in the settings. + + Args: + sender: The model class (WaffleFlagCourseOverrideModel) + instance: The flag override instance being saved + **kwargs: Additional keyword arguments from the signal + """ + if not settings.ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION: + logger.info("Automatic migration is disabled, skipping course migration") + return + + trigger_course_authoring_migration( + instance=instance, + scope_type=ScopeType.COURSE, + scope_key=str(instance.course_id), + ) + + +@receiver(pre_save, sender=WaffleFlagOrgOverrideModel) +def handle_org_waffle_flag_change(sender, instance, **kwargs) -> None: # pylint: disable=unused-argument + """Handle changes to organization-level waffle flags. + + When the authz.enable_course_authoring flag is changed for an organization, + trigger the appropriate migration run. Only trigger if automatic migration + is enabled in the settings. + + Args: + sender: The model class (WaffleFlagOrgOverrideModel) + instance: The flag override instance being saved + **kwargs: Additional keyword arguments from the signal + """ + if not settings.ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION: + logger.info("Automatic migration is disabled, skipping organization migration") + return + + trigger_course_authoring_migration( + instance=instance, + scope_type=ScopeType.ORG, + scope_key=str(instance.org), + ) diff --git a/openedx_authz/migrations/0008_authzcourseauthoringmigrationrun.py b/openedx_authz/migrations/0008_authzcourseauthoringmigrationrun.py new file mode 100644 index 00000000..90a4a1b5 --- /dev/null +++ b/openedx_authz/migrations/0008_authzcourseauthoringmigrationrun.py @@ -0,0 +1,82 @@ +# Generated by Django 5.2.12 on 2026-04-09 22:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("openedx_authz", "0007_coursescope"), + ] + + operations = [ + migrations.CreateModel( + name="AuthzCourseAuthoringMigrationRun", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "migration_type", + models.CharField( + choices=[("forward", "Legacy to AuthZ"), ("rollback", "AuthZ to Legacy")], + help_text="Direction of migration: forward (legacy → authz) or rollback (authz → legacy)", + max_length=20, + ), + ), + ( + "scope_type", + models.CharField( + choices=[("course", "Course"), ("org", "Organization")], + help_text="Type of scope being migrated: course or organization", + max_length=20, + ), + ), + ( + "scope_key", + models.CharField( + help_text="Identifier for the scope (e.g., course-v1:edX+DemoX+DemoCourse or org name)", + max_length=255, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("running", "Running"), + ("completed", "Completed"), + ("skipped", "Skipped"), + ], + default="pending", + help_text="Current status of the migration run", + max_length=20, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, help_text="When the migration run was created")), + ( + "updated_at", + models.DateTimeField(auto_now=True, help_text="When the migration run was last updated"), + ), + ( + "completed_at", + models.DateTimeField(blank=True, help_text="When the migration run was completed", null=True), + ), + ( + "metadata", + models.JSONField( + blank=True, + default=dict, + help_text="Additional metadata about the migration run (e.g., counts, warnings)", + ), + ), + ], + options={ + "verbose_name": "Course Authoring Migration Run", + "verbose_name_plural": "Course Authoring Migration Runs", + "ordering": ["-created_at"], + "indexes": [ + models.Index(fields=["scope_type", "scope_key"], name="openedx_aut_scope_t_d43a35_idx"), + models.Index(fields=["status"], name="openedx_aut_status_e34b60_idx"), + models.Index(fields=["-created_at"], name="openedx_aut_created_ab3e0a_idx"), + ], + }, + ), + ] diff --git a/openedx_authz/models/__init__.py b/openedx_authz/models/__init__.py index 4f0318a2..446dc9f9 100644 --- a/openedx_authz/models/__init__.py +++ b/openedx_authz/models/__init__.py @@ -16,5 +16,6 @@ """ from openedx_authz.models.core import * +from openedx_authz.models.migrations import * from openedx_authz.models.scopes import * from openedx_authz.models.subjects import * diff --git a/openedx_authz/models/migrations.py b/openedx_authz/models/migrations.py new file mode 100644 index 00000000..77f46bed --- /dev/null +++ b/openedx_authz/models/migrations.py @@ -0,0 +1,130 @@ +"""Models for tracking migration runs between legacy and AuthZ systems. + +.. no_pii: +""" + +from django.db import models +from django.utils import timezone + + +class MigrationType(models.TextChoices): + """Direction of migration.""" + + FORWARD = "forward", "Legacy to AuthZ" + ROLLBACK = "rollback", "AuthZ to Legacy" + + +class Status(models.TextChoices): + """Status of the migration task.""" + + PENDING = "pending", "Pending" + RUNNING = "running", "Running" + COMPLETED = "completed", "Completed" + SKIPPED = "skipped", "Skipped" + + +class ScopeType(models.TextChoices): + """Type of scope being migrated.""" + + COURSE = "course", "Course" + ORG = "org", "Organization" + + +class AuthzCourseAuthoringMigrationRun(models.Model): + """Track the status of course authoring migration tasks. + + This model is used to track async migrations between the legacy + CourseAccessRole system and the new AuthZ system. + """ + + migration_type = models.CharField( + max_length=20, + choices=MigrationType, + help_text="Direction of migration: forward (legacy → authz) or rollback (authz → legacy)", + ) + + scope_type = models.CharField( + max_length=20, + choices=ScopeType, + help_text="Type of scope being migrated: course or organization", + ) + + scope_key = models.CharField( + max_length=255, + help_text="Identifier for the scope (e.g., course-v1:edX+DemoX+DemoCourse or org name)", + ) + + status = models.CharField( + max_length=20, + choices=Status, + default=Status.PENDING, + help_text="Current status of the migration run", + ) + + created_at = models.DateTimeField( + auto_now_add=True, + help_text="When the migration run was created", + ) + + updated_at = models.DateTimeField( + auto_now=True, + help_text="When the migration run was last updated", + ) + + completed_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the migration run was completed", + ) + + metadata = models.JSONField( + default=dict, + blank=True, + help_text="Additional metadata about the migration run (e.g., counts, warnings)", + ) + + class Meta: + verbose_name = "Course Authoring Migration Run" + verbose_name_plural = "Course Authoring Migration Runs" + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["scope_type", "scope_key"]), + models.Index(fields=["status"]), + models.Index(fields=["-created_at"]), + ] + + @classmethod + def create_pending(cls, migration_type, scope_type, scope_key, metadata=None) -> "AuthzCourseAuthoringMigrationRun": + """Create a pending migration run.""" + return cls.objects.create( + migration_type=migration_type, + scope_type=scope_type, + scope_key=scope_key, + metadata=metadata or {}, + ) + + def mark_running(self) -> None: + """Mark the migration run as running.""" + self.status = Status.RUNNING + self.save(update_fields=["status", "updated_at"]) + + def mark_skipped(self, *, reason=None) -> None: + """Mark the migration run as skipped.""" + self.status = Status.SKIPPED + if reason: + self.metadata = {**(self.metadata or {}), "skip_reason": reason} + self.save(update_fields=["status", "updated_at", "metadata"]) + return + self.save(update_fields=["status", "updated_at"]) + + def mark_completed(self, *, metadata_updates=None) -> None: + """Mark the migration run as completed.""" + self.status = Status.COMPLETED + self.completed_at = timezone.now() + if metadata_updates: + self.metadata = {**(self.metadata or {}), **metadata_updates} + self.save(update_fields=["status", "completed_at", "updated_at", "metadata"]) + + def __str__(self) -> str: + """Return a string representation of the migration run.""" + return f"[{self.id}] {self.migration_type} {self.scope_type}:{self.scope_key} {self.status}" diff --git a/openedx_authz/settings/common.py b/openedx_authz/settings/common.py index 81e060b8..fd171efb 100644 --- a/openedx_authz/settings/common.py +++ b/openedx_authz/settings/common.py @@ -54,3 +54,8 @@ def plugin_settings(settings): # This setting defines the logging level for the Casbin enforcer. if not hasattr(settings, "CASBIN_LOG_LEVEL"): settings.CASBIN_LOG_LEVEL = "WARNING" + + # Set default ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION if not already set. + # This setting defines whether to enable automatic course migration. + if not hasattr(settings, "ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION"): + settings.ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION = False diff --git a/openedx_authz/settings/test.py b/openedx_authz/settings/test.py index ba449092..89b0eb21 100644 --- a/openedx_authz/settings/test.py +++ b/openedx_authz/settings/test.py @@ -78,3 +78,6 @@ def plugin_settings(settings): # pylint: disable=unused-argument # Use stub model for testing instead of the real content_libraries app OPENEDX_AUTHZ_CONTENT_LIBRARY_MODEL = "stubs.ContentLibrary" OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL = "stubs.CourseOverview" + +# Migration settings +ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION = False diff --git a/openedx_authz/tasks.py b/openedx_authz/tasks.py new file mode 100644 index 00000000..252dd0f4 --- /dev/null +++ b/openedx_authz/tasks.py @@ -0,0 +1,66 @@ +"""Celery tasks for course authoring migration. +These tasks handle asynchronous migration between legacy CourseAccessRole +and the new AuthZ system. +""" + +import logging + +from django.db import transaction + +# from celery import shared_task +from openedx_authz.engine.utils import migrate_authz_to_legacy_course_roles, migrate_legacy_course_roles_to_authz +from openedx_authz.models.migrations import MigrationType, ScopeType +from openedx_authz.models.subjects import UserSubject + +try: + from common.djangoapps.student.models import CourseAccessRole +except ImportError: + CourseAccessRole = None + +logger = logging.getLogger(__name__) + + +# @shared_task(bind=True) +def migrate_course_authoring_async( + # self, + migration_type: MigrationType | None, + scope_type: ScopeType, + scope_key: str, + course_id_list: list[str] | None = None, + org_id: str | None = None, + delete_after: bool = True, +): + """Asynchronously migrate course authoring roles between legacy and AuthZ systems. + Args: + migration_type: 'forward' (legacy→authz) or 'rollback' (authz→legacy) + scope_type: 'course' or 'org' + scope_key: Identifier for the scope + course_id_list: Optional list of course IDs to migrate (for course scope) + org_id: Optional organization ID to migrate (for org scope) + delete_after: Whether to delete source roles after successful migration + Returns: + dict: Migration result with status and metadata + """ + with transaction.atomic(): + if migration_type == MigrationType.FORWARD: + errors, successes = migrate_legacy_course_roles_to_authz( + course_access_role_model=CourseAccessRole, + course_id_list=course_id_list, + org_id=org_id, + delete_after_migration=delete_after, + ) + elif migration_type == MigrationType.ROLLBACK: + errors, successes = migrate_authz_to_legacy_course_roles( + course_access_role_model=CourseAccessRole, + user_subject_model=UserSubject, + course_id_list=course_id_list, + org_id=org_id, + delete_after_migration=delete_after, + ) + else: + raise ValueError(f"Invalid migration_type: {migration_type}") + + logger.info( + f"Completed {migration_type} migration for {scope_type}:{scope_key}. " + f"Successes: {len(successes)}, Errors: {len(errors)}" + ) diff --git a/requirements/base.in b/requirements/base.in index 99d7082a..b89904bc 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -13,3 +13,4 @@ edx-api-doc-tools # Tools for API documentation edx-django-utils # Used for RequestCache edx-drf-extensions # Extensions for Django Rest Framework used by Open edX edx-organizations # Organizations library for Open edX +celery # Asynchronous task queue diff --git a/requirements/base.txt b/requirements/base.txt index e9d01251..9331c19e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -4,12 +4,18 @@ # # pip-compile --output-file=requirements/base.txt requirements/base.in # +amqp==5.3.1 + # via kombu asgiref==3.9.1 # via django attrs==25.3.0 # via -r requirements/base.in +billiard==4.2.4 + # via celery casbin-django-orm-adapter==1.7.0 # via -r requirements/base.in +celery==5.6.3 + # via -r requirements/base.in certifi==2025.8.3 # via requests cffi==2.0.0 @@ -18,8 +24,19 @@ cffi==2.0.0 # pynacl charset-normalizer==3.4.3 # via requests -click==8.3.1 - # via edx-django-utils +click==8.3.2 + # via + # celery + # click-didyoumean + # click-plugins + # click-repl + # edx-django-utils +click-didyoumean==0.3.1 + # via celery +click-plugins==1.1.1.2 + # via celery +click-repl==0.3.0 + # via celery cryptography==46.0.2 # via pyjwt django==4.2.24 @@ -86,12 +103,18 @@ idna==3.10 # via requests inflection==0.5.1 # via drf-yasg +kombu==5.6.2 + # via celery openedx-atlas==0.7.0 # via -r requirements/base.in packaging==26.0 - # via drf-yasg + # via + # drf-yasg + # kombu pillow==12.1.1 # via edx-organizations +prompt-toolkit==3.0.52 + # via click-repl psutil==7.1.0 # via edx-django-utils pycasbin==2.2.0 @@ -108,6 +131,8 @@ pymongo==4.15.2 # via edx-opaque-keys pynacl==1.6.0 # via edx-django-utils +python-dateutil==2.9.0.post0 + # via celery pytz==2025.2 # via drf-yasg pyyaml==6.0.3 @@ -119,7 +144,9 @@ semantic-version==2.10.0 simpleeval==1.0.3 # via pycasbin six==1.17.0 - # via edx-ccx-keys + # via + # edx-ccx-keys + # python-dateutil sqlparse==0.5.3 # via django stevedore==5.5.0 @@ -128,10 +155,21 @@ stevedore==5.5.0 # edx-opaque-keys typing-extensions==4.15.0 # via edx-opaque-keys +tzdata==2026.1 + # via kombu +tzlocal==5.3.1 + # via celery uritemplate==4.2.0 # via drf-yasg urllib3==2.5.0 # via requests +vine==5.1.0 + # via + # amqp + # celery + # kombu +wcwidth==0.6.0 + # via prompt-toolkit # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/dev.txt b/requirements/dev.txt index b84f90f9..3401c5d2 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -4,6 +4,10 @@ # # pip-compile --output-file=requirements/dev.txt requirements/dev.in # +amqp==5.3.1 + # via + # -r requirements/quality.txt + # kombu asgiref==3.9.1 # via # -r requirements/quality.txt @@ -15,6 +19,10 @@ astroid==4.0.3 # pylint-celery attrs==25.3.0 # via -r requirements/quality.txt +billiard==4.2.4 + # via + # -r requirements/quality.txt + # celery build==1.4.2 # via # -r requirements/pip-tools.txt @@ -25,6 +33,8 @@ cachetools==6.2.6 # tox casbin-django-orm-adapter==1.7.0 # via -r requirements/quality.txt +celery==5.6.3 + # via -r requirements/quality.txt certifi==2025.8.3 # via # -r requirements/quality.txt @@ -43,19 +53,35 @@ charset-normalizer==3.4.3 # via # -r requirements/quality.txt # requests -click==8.3.1 +click==8.3.2 # via # -r requirements/pip-tools.txt # -r requirements/quality.txt + # celery + # click-didyoumean # click-log + # click-plugins + # click-repl # code-annotations # edx-django-utils # edx-lint # pip-tools +click-didyoumean==0.3.1 + # via + # -r requirements/quality.txt + # celery click-log==0.4.0 # via # -r requirements/quality.txt # edx-lint +click-plugins==1.1.1.2 + # via + # -r requirements/quality.txt + # celery +click-repl==0.3.0 + # via + # -r requirements/quality.txt + # celery code-annotations==2.3.0 # via # -r requirements/quality.txt @@ -188,6 +214,10 @@ jinja2==3.1.6 # -r requirements/quality.txt # code-annotations # diff-cover +kombu==5.6.2 + # via + # -r requirements/quality.txt + # celery lxml[html-clean]==6.0.1 # via # edx-i18n-tools @@ -211,6 +241,7 @@ packaging==26.0 # -r requirements/quality.txt # build # drf-yasg + # kombu # pyproject-api # pytest # tox @@ -240,6 +271,10 @@ pluggy==1.6.0 # tox polib==1.2.0 # via edx-i18n-tools +prompt-toolkit==3.0.52 + # via + # -r requirements/quality.txt + # click-repl psutil==7.1.0 # via # -r requirements/quality.txt @@ -310,6 +345,10 @@ pytest-cov==7.0.0 # via -r requirements/quality.txt pytest-django==4.11.1 # via -r requirements/quality.txt +python-dateutil==2.9.0.post0 + # via + # -r requirements/quality.txt + # celery python-slugify==8.0.4 # via # -r requirements/quality.txt @@ -343,6 +382,7 @@ six==1.17.0 # -r requirements/quality.txt # edx-ccx-keys # edx-lint + # python-dateutil snowballstemmer==3.0.1 # via # -r requirements/quality.txt @@ -371,6 +411,14 @@ typing-extensions==4.15.0 # via # -r requirements/quality.txt # edx-opaque-keys +tzdata==2026.1 + # via + # -r requirements/quality.txt + # kombu +tzlocal==5.3.1 + # via + # -r requirements/quality.txt + # celery uritemplate==4.2.0 # via # -r requirements/quality.txt @@ -379,10 +427,20 @@ urllib3==2.5.0 # via # -r requirements/quality.txt # requests +vine==5.1.0 + # via + # -r requirements/quality.txt + # amqp + # celery + # kombu virtualenv==20.36.1 # via # -r requirements/ci.txt # tox +wcwidth==0.6.0 + # via + # -r requirements/quality.txt + # prompt-toolkit wheel==0.46.3 # via # -r requirements/pip-tools.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index 5d74555a..c40a8255 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -8,6 +8,10 @@ accessible-pygments==0.0.5 # via pydata-sphinx-theme alabaster==1.0.0 # via sphinx +amqp==5.3.1 + # via + # -r requirements/test.txt + # kombu asgiref==3.9.1 # via # -r requirements/test.txt @@ -20,10 +24,16 @@ babel==2.17.0 # sphinx beautifulsoup4==4.13.5 # via pydata-sphinx-theme +billiard==4.2.4 + # via + # -r requirements/test.txt + # celery build==1.3.0 # via -r requirements/doc.in casbin-django-orm-adapter==1.7.0 # via -r requirements/test.txt +celery==5.6.3 + # via -r requirements/test.txt certifi==2025.8.3 # via # -r requirements/test.txt @@ -37,11 +47,27 @@ charset-normalizer==3.4.3 # via # -r requirements/test.txt # requests -click==8.3.1 +click==8.3.2 # via # -r requirements/test.txt + # celery + # click-didyoumean + # click-plugins + # click-repl # code-annotations # edx-django-utils +click-didyoumean==0.3.1 + # via + # -r requirements/test.txt + # celery +click-plugins==1.1.1.2 + # via + # -r requirements/test.txt + # celery +click-repl==0.3.0 + # via + # -r requirements/test.txt + # celery code-annotations==2.3.0 # via -r requirements/test.txt coverage[toml]==7.10.6 @@ -170,6 +196,10 @@ jinja2==3.1.6 # sphinx keyring==25.6.0 # via twine +kombu==5.6.2 + # via + # -r requirements/test.txt + # celery markdown-it-py==4.0.0 # via rich markupsafe==3.0.2 @@ -191,6 +221,7 @@ packaging==26.0 # -r requirements/test.txt # build # drf-yasg + # kombu # pydata-sphinx-theme # pytest # sphinx @@ -204,6 +235,10 @@ pluggy==1.6.0 # -r requirements/test.txt # pytest # pytest-cov +prompt-toolkit==3.0.52 + # via + # -r requirements/test.txt + # click-repl psutil==7.1.0 # via # -r requirements/test.txt @@ -252,6 +287,10 @@ pytest-cov==7.0.0 # via -r requirements/test.txt pytest-django==4.11.1 # via -r requirements/test.txt +python-dateutil==2.9.0.post0 + # via + # -r requirements/test.txt + # celery python-slugify==8.0.4 # via # -r requirements/test.txt @@ -299,6 +338,7 @@ six==1.17.0 # via # -r requirements/test.txt # edx-ccx-keys + # python-dateutil snowballstemmer==3.0.1 # via sphinx soupsieve==2.8 @@ -345,6 +385,14 @@ typing-extensions==4.15.0 # beautifulsoup4 # edx-opaque-keys # pydata-sphinx-theme +tzdata==2026.1 + # via + # -r requirements/test.txt + # kombu +tzlocal==5.3.1 + # via + # -r requirements/test.txt + # celery uritemplate==4.2.0 # via # -r requirements/test.txt @@ -354,6 +402,16 @@ urllib3==2.5.0 # -r requirements/test.txt # requests # twine +vine==5.1.0 + # via + # -r requirements/test.txt + # amqp + # celery + # kombu +wcwidth==0.6.0 + # via + # -r requirements/test.txt + # prompt-toolkit # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index d391764e..a5c65a01 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -6,7 +6,7 @@ # build==1.4.2 # via pip-tools -click==8.3.1 +click==8.3.2 # via pip-tools packaging==26.0 # via diff --git a/requirements/quality.txt b/requirements/quality.txt index 3c3af9d2..3a2acb01 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -4,6 +4,10 @@ # # pip-compile --output-file=requirements/quality.txt requirements/quality.in # +amqp==5.3.1 + # via + # -r requirements/test.txt + # kombu asgiref==3.9.1 # via # -r requirements/test.txt @@ -14,8 +18,14 @@ astroid==4.0.3 # pylint-celery attrs==25.3.0 # via -r requirements/test.txt +billiard==4.2.4 + # via + # -r requirements/test.txt + # celery casbin-django-orm-adapter==1.7.0 # via -r requirements/test.txt +celery==5.6.3 + # via -r requirements/test.txt certifi==2025.8.3 # via # -r requirements/test.txt @@ -29,15 +39,31 @@ charset-normalizer==3.4.3 # via # -r requirements/test.txt # requests -click==8.3.1 +click==8.3.2 # via # -r requirements/test.txt + # celery + # click-didyoumean # click-log + # click-plugins + # click-repl # code-annotations # edx-django-utils # edx-lint +click-didyoumean==0.3.1 + # via + # -r requirements/test.txt + # celery click-log==0.4.0 # via edx-lint +click-plugins==1.1.1.2 + # via + # -r requirements/test.txt + # celery +click-repl==0.3.0 + # via + # -r requirements/test.txt + # celery code-annotations==2.3.0 # via # -r requirements/test.txt @@ -147,6 +173,10 @@ jinja2==3.1.6 # via # -r requirements/test.txt # code-annotations +kombu==5.6.2 + # via + # -r requirements/test.txt + # celery markupsafe==3.0.2 # via # -r requirements/test.txt @@ -159,6 +189,7 @@ packaging==26.0 # via # -r requirements/test.txt # drf-yasg + # kombu # pytest pillow==12.1.1 # via @@ -171,6 +202,10 @@ pluggy==1.6.0 # -r requirements/test.txt # pytest # pytest-cov +prompt-toolkit==3.0.52 + # via + # -r requirements/test.txt + # click-repl psutil==7.1.0 # via # -r requirements/test.txt @@ -225,6 +260,10 @@ pytest-cov==7.0.0 # via -r requirements/test.txt pytest-django==4.11.1 # via -r requirements/test.txt +python-dateutil==2.9.0.post0 + # via + # -r requirements/test.txt + # celery python-slugify==8.0.4 # via # -r requirements/test.txt @@ -257,6 +296,7 @@ six==1.17.0 # -r requirements/test.txt # edx-ccx-keys # edx-lint + # python-dateutil snowballstemmer==3.0.1 # via pydocstyle sqlparse==0.5.3 @@ -279,6 +319,14 @@ typing-extensions==4.15.0 # via # -r requirements/test.txt # edx-opaque-keys +tzdata==2026.1 + # via + # -r requirements/test.txt + # kombu +tzlocal==5.3.1 + # via + # -r requirements/test.txt + # celery uritemplate==4.2.0 # via # -r requirements/test.txt @@ -287,6 +335,16 @@ urllib3==2.5.0 # via # -r requirements/test.txt # requests +vine==5.1.0 + # via + # -r requirements/test.txt + # amqp + # celery + # kombu +wcwidth==0.6.0 + # via + # -r requirements/test.txt + # prompt-toolkit # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/test.txt b/requirements/test.txt index 244228fb..eda4ebff 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -4,14 +4,24 @@ # # pip-compile --output-file=requirements/test.txt requirements/test.in # +amqp==5.3.1 + # via + # -r requirements/base.txt + # kombu asgiref==3.9.1 # via # -r requirements/base.txt # django attrs==25.3.0 # via -r requirements/base.txt +billiard==4.2.4 + # via + # -r requirements/base.txt + # celery casbin-django-orm-adapter==1.7.0 # via -r requirements/base.txt +celery==5.6.3 + # via -r requirements/base.txt certifi==2025.8.3 # via # -r requirements/base.txt @@ -25,11 +35,27 @@ charset-normalizer==3.4.3 # via # -r requirements/base.txt # requests -click==8.3.1 +click==8.3.2 # via # -r requirements/base.txt + # celery + # click-didyoumean + # click-plugins + # click-repl # code-annotations # edx-django-utils +click-didyoumean==0.3.1 + # via + # -r requirements/base.txt + # celery +click-plugins==1.1.1.2 + # via + # -r requirements/base.txt + # celery +click-repl==0.3.0 + # via + # -r requirements/base.txt + # celery code-annotations==2.3.0 # via -r requirements/test.in coverage[toml]==7.10.6 @@ -124,6 +150,10 @@ iniconfig==2.1.0 # via pytest jinja2==3.1.6 # via code-annotations +kombu==5.6.2 + # via + # -r requirements/base.txt + # celery markupsafe==3.0.2 # via jinja2 openedx-atlas==0.7.0 @@ -132,6 +162,7 @@ packaging==26.0 # via # -r requirements/base.txt # drf-yasg + # kombu # pytest pillow==12.1.1 # via @@ -141,6 +172,10 @@ pluggy==1.6.0 # via # pytest # pytest-cov +prompt-toolkit==3.0.52 + # via + # -r requirements/base.txt + # click-repl psutil==7.1.0 # via # -r requirements/base.txt @@ -176,6 +211,10 @@ pytest-cov==7.0.0 # via -r requirements/test.in pytest-django==4.11.1 # via -r requirements/test.in +python-dateutil==2.9.0.post0 + # via + # -r requirements/base.txt + # celery python-slugify==8.0.4 # via code-annotations pytz==2025.2 @@ -203,6 +242,7 @@ six==1.17.0 # via # -r requirements/base.txt # edx-ccx-keys + # python-dateutil sqlparse==0.5.3 # via # -r requirements/base.txt @@ -219,6 +259,14 @@ typing-extensions==4.15.0 # via # -r requirements/base.txt # edx-opaque-keys +tzdata==2026.1 + # via + # -r requirements/base.txt + # kombu +tzlocal==5.3.1 + # via + # -r requirements/base.txt + # celery uritemplate==4.2.0 # via # -r requirements/base.txt @@ -227,6 +275,16 @@ urllib3==2.5.0 # via # -r requirements/base.txt # requests +vine==5.1.0 + # via + # -r requirements/base.txt + # amqp + # celery + # kombu +wcwidth==0.6.0 + # via + # -r requirements/base.txt + # prompt-toolkit # The following packages are considered to be unsafe in a requirements file: # setuptools