Skip to content

Commit b0463fa

Browse files
committed
fixup! feat: add course authoring migration and rollback scripts
1 parent f1bf790 commit b0463fa

4 files changed

Lines changed: 135 additions & 51 deletions

File tree

openedx_authz/engine/utils.py

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,6 @@
1616
get_user_role_assignments,
1717
)
1818
from openedx_authz.constants.roles import (
19-
COURSE_ADMIN,
20-
COURSE_DATA_RESEARCHER,
21-
COURSE_LIMITED_STAFF,
22-
COURSE_STAFF,
2319
LEGACY_COURSE_ROLE_EQUIVALENCES,
2420
LIBRARY_ADMIN,
2521
LIBRARY_AUTHOR,
@@ -172,7 +168,7 @@ def migrate_legacy_permissions(ContentLibraryPermission):
172168
return permissions_with_errors
173169

174170

175-
def migrate_legacy_course_roles_to_authz(CourseAccessRole, delete_after_migration):
171+
def migrate_legacy_course_roles_to_authz(CourseAccessRole, course_id_list, org_id, delete_after_migration):
176172
"""
177173
Migrate legacy course role data to the new Casbin-based authorization model.
178174
This function reads legacy permissions from the CourseAccessRole model
@@ -193,10 +189,23 @@ def migrate_legacy_course_roles_to_authz(CourseAccessRole, delete_after_migratio
193189
194190
param CourseAccessRole: The CourseAccessRole model to use.
195191
"""
192+
if not course_id_list and not org_id:
193+
raise ValueError(
194+
"At least one of course_id_list or org_id must be provided to limit the scope of the rollback migration."
195+
)
196+
course_access_role_filter = {
197+
"course_id__startswith": "course-v1:",
198+
}
199+
200+
if org_id:
201+
course_access_role_filter["org"] = org_id
202+
203+
if course_id_list and not org_id:
204+
# Only filter by course_id if org_id is not provided,
205+
# otherwise we will filter by org_id which is more efficient
206+
course_access_role_filter["course_id__in"] = course_id_list
196207

197-
legacy_permissions = (
198-
CourseAccessRole.objects.filter(course_id__startswith="course-v1:").select_related("user").all()
199-
)
208+
legacy_permissions = CourseAccessRole.objects.filter(**course_access_role_filter).select_related("user").all()
200209

201210
# List to keep track of any permissions that could not be migrated
202211
permissions_with_errors = []
@@ -205,15 +214,7 @@ def migrate_legacy_course_roles_to_authz(CourseAccessRole, delete_after_migratio
205214
for permission in legacy_permissions:
206215
# Migrate the permission to the new model
207216

208-
# Derive equivalent role based on access level
209-
map_legacy_role = {
210-
"instructor": COURSE_ADMIN,
211-
"staff": COURSE_STAFF,
212-
"limited_staff": COURSE_LIMITED_STAFF,
213-
"data_researcher": COURSE_DATA_RESEARCHER,
214-
}
215-
216-
role = map_legacy_role.get(permission.role)
217+
role = LEGACY_COURSE_ROLE_EQUIVALENCES.get(permission.role)
217218
if role is None:
218219
# This should not happen as there are no more access_levels defined
219220
# in CourseAccessRole, log and skip
@@ -224,32 +225,33 @@ def migrate_legacy_course_roles_to_authz(CourseAccessRole, delete_after_migratio
224225
# Permission applied to individual user
225226
logger.info(
226227
f"Migrating permission for User: {permission.user.username} "
227-
f"to Role: {role.external_key} in Scope: {permission.course_id}"
228+
f"to Role: {role} in Scope: {permission.course_id}"
228229
)
229230

230231
is_user_added = assign_role_to_user_in_scope(
231232
user_external_key=permission.user.username,
232-
role_external_key=role.external_key,
233+
role_external_key=role,
233234
scope_external_key=str(permission.course_id),
234235
)
235236

236237
if not is_user_added:
237238
logger.error(
238239
f"Failed to migrate permission for User: {permission.user.username} "
239-
f"to Role: {role.external_key} in Scope: {permission.course_id}"
240+
f"to Role: {role} in Scope: {permission.course_id}"
240241
)
241242
permissions_with_errors.append(permission)
242243
continue
243244

244245
permissions_with_no_errors.append(permission)
245246

246247
if delete_after_migration:
248+
# Only delete permissions that were successfully migrated to avoid data loss.
247249
CourseAccessRole.objects.filter(id__in=[p.id for p in permissions_with_no_errors]).delete()
248250

249-
return permissions_with_errors
251+
return permissions_with_errors, permissions_with_no_errors
250252

251253

252-
def migrate_authz_to_legacy_course_roles(CourseAccessRole, UserSubject, delete_after_migration):
254+
def migrate_authz_to_legacy_course_roles(CourseAccessRole, UserSubject, course_id_list, org_id, delete_after_migration):
253255
"""
254256
Migrate permissions from the new Casbin-based authorization model back to the legacy CourseAccessRole model.
255257
This function reads permissions from the Casbin enforcer and creates equivalent entries in the
@@ -258,15 +260,29 @@ def migrate_authz_to_legacy_course_roles(CourseAccessRole, UserSubject, delete_a
258260
This is essentially the reverse of migrate_legacy_course_roles_to_authz and is intended
259261
for rollback purposes in case of migration issues.
260262
"""
263+
if not course_id_list and not org_id:
264+
raise ValueError(
265+
"At least one of course_id_list or org_id must be provided to limit the scope of the rollback migration."
266+
)
267+
261268
# 1. Get all users with course-related permissions in the new model by filtering
262269
# UserSubjects that are linked to CourseScopes with a valid course overview.
263-
course_subjects = (
264-
UserSubject.objects.filter(casbin_rules__scope__coursescope__course_overview__isnull=False)
265-
.select_related("user")
266-
.distinct()
267-
)
270+
course_subject_filter = {
271+
"casbin_rules__scope__coursescope__course_overview__isnull": False,
272+
}
273+
274+
if org_id:
275+
course_subject_filter["casbin_rules__scope__coursescope__course_overview__org"] = org_id
276+
277+
if course_id_list and not org_id:
278+
# Only filter by course_id if org_id is not provided,
279+
# otherwise we will filter by org_id which is more efficient
280+
course_subject_filter["casbin_rules__scope__coursescope__course_overview__id__in"] = course_id_list
281+
282+
course_subjects = UserSubject.objects.filter(**course_subject_filter).select_related("user").distinct()
268283

269284
roles_with_errors = []
285+
roles_with_no_errors = []
270286

271287
for course_subject in course_subjects:
272288
user = course_subject.user
@@ -299,6 +315,7 @@ def migrate_authz_to_legacy_course_roles(CourseAccessRole, UserSubject, delete_a
299315
course_id=scope,
300316
role=legacy_role,
301317
)
318+
roles_with_no_errors.append((user_external_key, role.external_key, scope))
302319
except Exception as e: # pylint: disable=broad-exception-caught
303320
logger.error(
304321
f"Error creating CourseAccessRole for User: "
@@ -314,4 +331,4 @@ def migrate_authz_to_legacy_course_roles(CourseAccessRole, UserSubject, delete_a
314331
role_external_key=role.external_key,
315332
scope_external_key=scope,
316333
)
317-
return roles_with_errors
334+
return roles_with_errors, roles_with_no_errors

openedx_authz/management/commands/authz_migrate_course_authoring.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Django management command to migrate legacy course authoring roles to the new Authz (Casbin-based) authorization system.
33
"""
44

5-
from django.core.management.base import BaseCommand
5+
from django.core.management.base import BaseCommand, CommandError
66
from django.db import transaction
77

88
from openedx_authz.engine.utils import migrate_legacy_course_roles_to_authz
@@ -27,9 +27,29 @@ def add_arguments(self, parser):
2727
action="store_true",
2828
help="Delete legacy CourseAccessRole records after successful migration.",
2929
)
30+
parser.add_argument(
31+
"--course-id-list",
32+
nargs="*",
33+
type=str,
34+
help="Optional list of course IDs to filter the migration.",
35+
)
36+
37+
parser.add_argument(
38+
"--org-id",
39+
type=str,
40+
help="Optional organization ID to filter the migration.",
41+
)
3042

3143
def handle(self, *args, **options):
3244
delete_after_migration = options["delete"]
45+
course_id_list = options.get("course_id_list")
46+
org_id = options.get("org_id")
47+
48+
if not course_id_list and not org_id:
49+
raise CommandError("You must specify either --course-id-list or --org-id to filter the rollback.")
50+
51+
if course_id_list and org_id:
52+
raise CommandError("You cannot use --course-id-list and --org-id together.")
3353

3454
self.stdout.write(self.style.WARNING("Starting legacy → Authz migration..."))
3555

@@ -43,18 +63,22 @@ def handle(self, *args, **options):
4363
self.stdout.write(self.style.WARNING("Deletion aborted."))
4464
return
4565
with transaction.atomic():
46-
errors = migrate_legacy_course_roles_to_authz(
66+
errors, success = migrate_legacy_course_roles_to_authz(
4767
CourseAccessRole=CourseAccessRole,
68+
course_id_list=course_id_list,
69+
org_id=org_id,
4870
delete_after_migration=delete_after_migration,
4971
)
5072

5173
if errors:
5274
self.stdout.write(self.style.ERROR(f"Migration completed with {len(errors)} errors."))
5375
else:
54-
self.stdout.write(self.style.SUCCESS("Migration completed successfully with no errors."))
76+
self.stdout.write(
77+
self.style.SUCCESS(f"Migration completed successfully with {len(success)} roles migrated.")
78+
)
5579

5680
if delete_after_migration:
57-
self.stdout.write(self.style.SUCCESS("Legacy roles deleted successfully."))
81+
self.stdout.write(self.style.SUCCESS(f"{len(success)} Legacy roles deleted successfully."))
5882

5983
except Exception as exc:
6084
self.stdout.write(self.style.ERROR(f"Migration failed due to unexpected error: {exc}"))

openedx_authz/management/commands/authz_rollback_course_authoring.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
authorization system back to the legacy CourseAccessRole model.
44
"""
55

6-
from django.core.management.base import BaseCommand
6+
from django.core.management.base import BaseCommand, CommandError
77
from django.db import transaction
88

99
from openedx_authz.engine.utils import migrate_authz_to_legacy_course_roles
@@ -29,9 +29,29 @@ def add_arguments(self, parser):
2929
action="store_true",
3030
help="Delete Authz role assignments after successful rollback.",
3131
)
32+
parser.add_argument(
33+
"--course-id-list",
34+
nargs="*",
35+
type=str,
36+
help="Optional list of course IDs to filter the migration.",
37+
)
38+
39+
parser.add_argument(
40+
"--org-id",
41+
type=str,
42+
help="Optional organization ID to filter the migration.",
43+
)
3244

3345
def handle(self, *args, **options):
3446
delete_after_migration = options["delete"]
47+
course_id_list = options.get("course_id_list")
48+
org_id = options.get("org_id")
49+
50+
if not course_id_list and not org_id:
51+
raise CommandError("You must specify either --course-id-list or --org-id to filter the rollback.")
52+
53+
if course_id_list and org_id:
54+
raise CommandError("You cannot use --course-id-list and --org-id together.")
3555

3656
self.stdout.write(self.style.WARNING("Starting Authz → Legacy rollback migration..."))
3757

@@ -46,19 +66,25 @@ def handle(self, *args, **options):
4666
self.stdout.write(self.style.WARNING("Deletion aborted."))
4767
return
4868
with transaction.atomic():
49-
errors = migrate_authz_to_legacy_course_roles(
69+
errors, success = migrate_authz_to_legacy_course_roles(
5070
CourseAccessRole=CourseAccessRole,
5171
UserSubject=UserSubject,
72+
course_id_list=course_id_list,
73+
org_id=org_id,
5274
delete_after_migration=delete_after_migration, # control deletion here
5375
)
5476

5577
if errors:
5678
self.stdout.write(self.style.ERROR(f"Rollback completed with {len(errors)} errors."))
5779
else:
58-
self.stdout.write(self.style.SUCCESS("Rollback completed successfully with no errors."))
80+
self.stdout.write(
81+
self.style.SUCCESS(f"Rollback completed successfully with {len(success)} roles rolled back.")
82+
)
5983

6084
if delete_after_migration:
61-
self.stdout.write(self.style.SUCCESS("Authz role assignments removed successfully."))
85+
self.stdout.write(
86+
self.style.SUCCESS(f"{len(success)} Authz role assignments removed successfully.")
87+
)
6288

6389
except Exception as exc:
6490
self.stdout.write(self.style.ERROR(f"Rollback failed due to unexpected error: {exc}"))

0 commit comments

Comments
 (0)