88from collections import defaultdict
99
1010from casbin import Enforcer
11+ from django .db .models import Q
12+ from opaque_keys .edx .django .models import CourseKeyField
1113
12- from openedx_authz .api .data import CourseOverviewData
14+ from openedx_authz .api .data import CourseOverviewData , OrgCourseOverviewGlobData
15+ from openedx_authz .api .roles import get_all_role_assignments_per_scope_type
1316from openedx_authz .api .users import (
1417 assign_role_to_user_in_scope ,
1518 batch_assign_role_to_users_in_scope ,
1619 batch_unassign_role_from_users ,
17- get_user_role_assignments ,
1820)
1921from openedx_authz .constants .roles import (
2022 LEGACY_COURSE_ROLE_EQUIVALENCES ,
@@ -204,6 +206,11 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis
204206 - user: subject
205207 - role: role
206208
209+ The scope assigned per row depends on which fields are set:
210+ - course_id set: course-level scope (e.g. "course-v1:OpenedX+CS101+2024").
211+ - course_id blank, org set: org-level glob scope (e.g. "course-v1:OpenedX+*").
212+ - both set: course_id takes precedence as the more specific scope.
213+
207214 param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function
208215 is intended to run within a Django migration context, where direct model imports can cause issues.
209216 param course_id_list: Optional list of course IDs to filter the migration.
@@ -212,9 +219,7 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis
212219 """
213220 _validate_migration_input (course_id_list , org_id )
214221
215- course_access_role_filter = {
216- "course_id__startswith" : "course-v1:" ,
217- }
222+ course_access_role_filter = {}
218223
219224 if org_id :
220225 course_access_role_filter ["org" ] = org_id
@@ -225,7 +230,9 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis
225230 course_access_role_filter ["course_id__in" ] = course_id_list
226231
227232 legacy_permissions = (
228- course_access_role_model .objects .filter (** course_access_role_filter ).select_related ("user" ).all ()
233+ course_access_role_model .objects .filter (** course_access_role_filter )
234+ .filter (Q (course_id = CourseKeyField .Empty ) | Q (course_id__startswith = CourseOverviewData .NAMESPACE ))
235+ .select_related ("user" )
229236 )
230237
231238 # List to keep track of any permissions that could not be migrated
@@ -243,16 +250,28 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis
243250 permissions_with_errors .append (permission )
244251 continue
245252
253+ if permission .course_id :
254+ scope_external_key = str (permission .course_id )
255+ elif permission .org :
256+ scope_external_key = OrgCourseOverviewGlobData .build_external_key (permission .org )
257+ else :
258+ # Instance-wide roles (no course_id, no org) are not supported by this migration, log and skip.
259+ logger .error (
260+ f"Permission for User: { permission .user .username } has neither course_id nor org defined, skipping."
261+ )
262+ permissions_with_errors .append (permission )
263+ continue
264+
246265 # Permission applied to individual user
247266 logger .info (
248267 f"Migrating permission for User: { permission .user .username } "
249- f"to Role: { role } in Scope: { permission . course_id } "
268+ f"to Role: { role } in Scope: { scope_external_key } "
250269 )
251270
252271 is_user_added = assign_role_to_user_in_scope (
253272 user_external_key = permission .user .username ,
254273 role_external_key = role ,
255- scope_external_key = str ( permission . course_id ) ,
274+ scope_external_key = scope_external_key ,
256275 )
257276
258277 if not is_user_added :
@@ -286,6 +305,11 @@ def migrate_authz_to_legacy_course_roles(
286305 This is essentially the reverse of migrate_legacy_course_roles_to_authz and is intended
287306 for rollback purposes in case of migration issues.
288307
308+ To build each CourseAccessRole entry, the function needs:
309+ - A user: resolved from role assignments in scopes linked to courses.
310+ - A scope: a CourseOverviewData or OrgCourseOverviewGlobData instance, optionally filtered by course_id or org_id.
311+ - A role: a role external key that maps to a legacy role in COURSE_ROLE_EQUIVALENCES.
312+
289313 param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function
290314 is intended to run within a Django migration context, where direct model imports can cause issues.
291315 param user_subject_model: It should be the UserSubject model. This is passed in because the function
@@ -297,70 +321,87 @@ def migrate_authz_to_legacy_course_roles(
297321 """
298322 _validate_migration_input (course_id_list , org_id )
299323
300- # 1. Get all users with course-related permissions in the new model by filtering
301- # UserSubjects that are linked to CourseScopes with a valid course overview.
302- course_subject_filter = {
303- "casbin_rules__scope__coursescope__course_overview__isnull" : False ,
304- }
324+ role_assignments = get_all_role_assignments_per_scope_type (
325+ scope_types = (CourseOverviewData , OrgCourseOverviewGlobData ,)
326+ )
305327
328+ # Two cases here:
329+ # 1. org_id provided: filter by org — includes org-level glob and course-level scopes for that org.
330+ # 2. only course_id_list provided: filter by course_id — org-level glob scopes are excluded (no course_id).
306331 if org_id :
307- course_subject_filter ["casbin_rules__scope__coursescope__course_overview__org" ] = org_id
332+ role_assignments = [
333+ role_assignment
334+ for role_assignment in role_assignments
335+ if role_assignment .scope .org == org_id
336+ ]
308337
309338 if course_id_list and not org_id :
310- # Only filter by course_id if org_id is not provided,
311- # otherwise we will filter by org_id which is more efficient
312- course_subject_filter ["casbin_rules__scope__coursescope__course_overview__id__in" ] = course_id_list
313-
314- course_subjects = user_subject_model .objects .filter (** course_subject_filter ).select_related ("user" ).distinct ()
339+ role_assignments = [
340+ role_assignment
341+ for role_assignment in role_assignments
342+ if isinstance (role_assignment .scope , CourseOverviewData ) and
343+ role_assignment .scope .course_id in course_id_list
344+ ]
315345
316346 roles_with_errors = []
317347 roles_with_no_errors = []
318348 unassignments = defaultdict (list )
319349
320- for course_subject in course_subjects :
321- user = course_subject .user
322- user_external_key = user .username
323-
324- # 2. Get all role assignments for the user
325- role_assignments = get_user_role_assignments (user_external_key = user_external_key )
350+ user_external_keys = {assignment .subject .external_key for assignment in role_assignments }
351+ users_by_username = {
352+ subject .user .username : subject .user
353+ for subject in user_subject_model .objects .filter (
354+ user__username__in = user_external_keys
355+ ).select_related ("user" )
356+ }
326357
327- for assignment in role_assignments :
328- if not isinstance (assignment .scope , CourseOverviewData ):
329- logger .error (f"Skipping role assignment for User: { user_external_key } due to missing course scope." )
358+ for role_assignment in role_assignments :
359+
360+ # Per valid role assignment, create corresponding CourseAccessRole entry
361+ # depending on whether the scope is course-level or org-level glob
362+ try :
363+ user_external_key = role_assignment .subject .external_key
364+ role_external_key = role_assignment .roles [0 ].external_key
365+ scope_external_key = role_assignment .scope .external_key
366+
367+ course_access_role_kwargs = {
368+ "user" : users_by_username [user_external_key ],
369+ "role" : COURSE_ROLE_EQUIVALENCES [role_external_key ],
370+ }
371+
372+ if isinstance (role_assignment .scope , CourseOverviewData ):
373+ course_access_role_kwargs ["org" ] = role_assignment .scope .org
374+ course_access_role_kwargs ["course_id" ] = scope_external_key
375+ elif isinstance (role_assignment .scope , OrgCourseOverviewGlobData ):
376+ course_access_role_kwargs ["org" ] = role_assignment .scope .org
377+ else :
378+ # This would only happen for course roles assigned instance-wide
379+ # which is not yet supported
380+ logger .error (
381+ f"Unexpected scope type: { type (role_assignment .scope )} for RoleAssignment with "
382+ f"scope: { scope_external_key } , user: { user_external_key } and role: { role_external_key } , skipping."
383+ )
384+ roles_with_errors .append (role_assignment )
330385 continue
331386
332- scope = assignment .scope .external_key
333-
334- course_overview = assignment .scope .get_object ()
335-
336- for role in assignment .roles :
337- legacy_role = COURSE_ROLE_EQUIVALENCES .get (role .external_key )
338- if legacy_role is None :
339- logger .error (f"Unknown role: { role } for User: { user_external_key } " )
340- roles_with_errors .append ((user_external_key , role .external_key , scope ))
341- continue
342-
343- try :
344- # Create legacy CourseAccessRole entry
345- course_access_role_model .objects .get_or_create (
346- user = user ,
347- org = course_overview .org ,
348- course_id = scope ,
349- role = legacy_role ,
350- )
351- roles_with_no_errors .append ((user_external_key , role .external_key , scope ))
352- except Exception as e : # pylint: disable=broad-exception-caught
353- logger .error (
354- f"Error creating CourseAccessRole for User: "
355- f"{ user_external_key } , Role: { legacy_role } , Course: { scope } : { e } "
356- )
357- roles_with_errors .append ((user_external_key , role .external_key , scope ))
358- continue
359-
360- # If we successfully created the legacy role, we can add this role assignment
361- # to the unassignment list if delete_after_migration is True
362- if delete_after_migration :
363- unassignments [(role .external_key , scope )].append (user_external_key )
387+ course_access_role_model .objects .get_or_create (** course_access_role_kwargs )
388+ roles_with_no_errors .append (role_assignment )
389+
390+ logger .info (
391+ f"Successfully rolled back RoleAssignment for User: { user_external_key } "
392+ f"in Role: { role_external_key } and Scope: { scope_external_key } "
393+ f"to legacy CourseAccessRole entry."
394+ )
395+
396+ if delete_after_migration :
397+ unassignments [(role_external_key , scope_external_key )].append (user_external_key )
398+
399+ except Exception as e : # pylint: disable=broad-exception-caught
400+ logger .error (
401+ f"Error rolling back RoleAssignment for User: { role_assignment .subject .external_key } "
402+ f"in Role: { role_assignment .roles [0 ].external_key } and Scope: { role_assignment .scope .external_key } : { e } "
403+ )
404+ roles_with_errors .append (role_assignment )
364405
365406 # Once the loop is done, we can log summary of unassignments
366407 # and perform batch unassignment if delete_after_migration is True
0 commit comments