Skip to content

Commit 4727f2c

Browse files
committed
feat: add course authoring automatic migration mechanism
1 parent 61e318d commit 4727f2c

16 files changed

Lines changed: 1002 additions & 114 deletions

File tree

openedx_authz/admin.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django import forms
55
from django.contrib import admin
66

7-
from openedx_authz.models import ExtendedCasbinRule
7+
from openedx_authz.models import AuthzCourseAuthoringMigrationRun, ExtendedCasbinRule
88

99

1010
class CasbinRuleForm(forms.ModelForm):
@@ -48,3 +48,12 @@ class CasbinRuleAdmin(admin.ModelAdmin):
4848
# TODO: In a future, possibly we should only show an inline for the rules that
4949
# have an extended rule, and show the subject and scope information in detail.
5050
inlines = [ExtendedCasbinRuleInline]
51+
52+
53+
@admin.register(AuthzCourseAuthoringMigrationRun)
54+
class AuthzCourseAuthoringMigrationRunAdmin(admin.ModelAdmin):
55+
"""Admin for AuthzCourseAuthoringMigrationRun to display additional metadata."""
56+
57+
list_display = ("id", "scope_type", "scope_key", "migration_type", "status", "created_at", "updated_at")
58+
search_fields = ("scope_type", "scope_key", "migration_type", "status")
59+
list_filter = ("scope_type", "migration_type", "status")

openedx_authz/engine/utils.py

Lines changed: 311 additions & 103 deletions
Large diffs are not rendered by default.

openedx_authz/handlers.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,32 @@
77
import logging
88

99
from casbin_adapter.models import CasbinRule
10-
from django.db.models.signals import post_delete
10+
from django.conf import settings
11+
from django.db.models.signals import post_delete, pre_save
1112
from django.dispatch import receiver
1213

1314
from openedx_authz.api.users import unassign_all_roles_from_user
1415
from openedx_authz.models.core import ExtendedCasbinRule
16+
from openedx_authz.models.migrations import MigrationType, ScopeType
17+
from openedx_authz.tasks import migrate_course_authoring_async
1518

1619
try:
1720
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL
1821
except ImportError:
1922
USER_RETIRE_LMS_CRITICAL = None
2023

24+
try:
25+
from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel, WaffleFlagOrgOverrideModel
26+
except ImportError:
27+
WaffleFlagCourseOverrideModel = None
28+
WaffleFlagOrgOverrideModel = None
29+
30+
2131
logger = logging.getLogger(__name__)
2232

33+
# Flag name to monitor for automatic migration
34+
AUTHZ_COURSE_AUTHORING_FLAG = "authz.enable_course_authoring"
35+
2336

2437
@receiver(post_delete, sender=ExtendedCasbinRule)
2538
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
8295
# Only register the handler if the signal is available (i.e., running in Open edX)
8396
if USER_RETIRE_LMS_CRITICAL is not None:
8497
USER_RETIRE_LMS_CRITICAL.connect(unassign_roles_on_user_retirement)
98+
99+
100+
def trigger_course_authoring_migration(
101+
instance: WaffleFlagCourseOverrideModel | WaffleFlagOrgOverrideModel,
102+
scope_type: ScopeType,
103+
scope_key: str,
104+
) -> None:
105+
"""Trigger an asynchronous migration run.
106+
107+
Args:
108+
instance: The waffle flag instance that triggered the migration
109+
scope_type (ScopeType): Type of scope being migrated: course or organization
110+
scope_key (str): Course ID or organization name
111+
"""
112+
if instance.waffle_flag != AUTHZ_COURSE_AUTHORING_FLAG:
113+
return
114+
115+
last_flag_obj = None
116+
if isinstance(instance, WaffleFlagCourseOverrideModel):
117+
last_flag_obj = (
118+
WaffleFlagCourseOverrideModel.objects.filter(course_id=instance.course_id).order_by("-id").first()
119+
)
120+
elif isinstance(instance, WaffleFlagOrgOverrideModel):
121+
last_flag_obj = WaffleFlagOrgOverrideModel.objects.filter(org=instance.org).order_by("-id").first()
122+
123+
if last_flag_obj and last_flag_obj.enabled == instance.enabled:
124+
logger.info("No change in waffle flag, skipping course migration")
125+
return
126+
127+
if not instance.enabled:
128+
migration_type = MigrationType.ROLLBACK
129+
else:
130+
migration_type = MigrationType.FORWARD
131+
132+
course_id_list = None
133+
org_id = None
134+
135+
if scope_type == ScopeType.COURSE:
136+
course_id_list = [scope_key]
137+
elif scope_type == ScopeType.ORG:
138+
org_id = scope_key
139+
140+
logger.info(f"Triggering {migration_type} migration for {scope_type}:{scope_key} due to waffle flag change")
141+
142+
migrate_course_authoring_async(
143+
migration_type=migration_type,
144+
scope_type=scope_type,
145+
scope_key=scope_key,
146+
course_id_list=course_id_list,
147+
org_id=org_id,
148+
delete_after=True,
149+
)
150+
151+
152+
@receiver(pre_save, sender=WaffleFlagCourseOverrideModel)
153+
def handle_course_waffle_flag_change(sender, instance, **kwargs) -> None: # pylint: disable=unused-argument
154+
"""Handle changes to course-level waffle flags.
155+
156+
When the authz.enable_course_authoring flag is changed for a course,
157+
trigger the appropriate migration run. Only trigger if automatic migration
158+
is enabled in the settings.
159+
160+
Args:
161+
sender: The model class (WaffleFlagCourseOverrideModel)
162+
instance: The flag override instance being saved
163+
**kwargs: Additional keyword arguments from the signal
164+
"""
165+
if not settings.ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION:
166+
logger.info("Automatic migration is disabled, skipping course migration")
167+
return
168+
169+
trigger_course_authoring_migration(
170+
instance=instance,
171+
scope_type=ScopeType.COURSE,
172+
scope_key=str(instance.course_id),
173+
)
174+
175+
176+
@receiver(pre_save, sender=WaffleFlagOrgOverrideModel)
177+
def handle_org_waffle_flag_change(sender, instance, **kwargs) -> None: # pylint: disable=unused-argument
178+
"""Handle changes to organization-level waffle flags.
179+
180+
When the authz.enable_course_authoring flag is changed for an organization,
181+
trigger the appropriate migration run. Only trigger if automatic migration
182+
is enabled in the settings.
183+
184+
Args:
185+
sender: The model class (WaffleFlagOrgOverrideModel)
186+
instance: The flag override instance being saved
187+
**kwargs: Additional keyword arguments from the signal
188+
"""
189+
if not settings.ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION:
190+
logger.info("Automatic migration is disabled, skipping organization migration")
191+
return
192+
193+
trigger_course_authoring_migration(
194+
instance=instance,
195+
scope_type=ScopeType.ORG,
196+
scope_key=str(instance.org),
197+
)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Generated by Django 5.2.12 on 2026-04-09 22:46
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("openedx_authz", "0007_coursescope"),
9+
]
10+
11+
operations = [
12+
migrations.CreateModel(
13+
name="AuthzCourseAuthoringMigrationRun",
14+
fields=[
15+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
16+
(
17+
"migration_type",
18+
models.CharField(
19+
choices=[("forward", "Legacy to AuthZ"), ("rollback", "AuthZ to Legacy")],
20+
help_text="Direction of migration: forward (legacy → authz) or rollback (authz → legacy)",
21+
max_length=20,
22+
),
23+
),
24+
(
25+
"scope_type",
26+
models.CharField(
27+
choices=[("course", "Course"), ("org", "Organization")],
28+
help_text="Type of scope being migrated: course or organization",
29+
max_length=20,
30+
),
31+
),
32+
(
33+
"scope_key",
34+
models.CharField(
35+
help_text="Identifier for the scope (e.g., course-v1:edX+DemoX+DemoCourse or org name)",
36+
max_length=255,
37+
),
38+
),
39+
(
40+
"status",
41+
models.CharField(
42+
choices=[
43+
("pending", "Pending"),
44+
("running", "Running"),
45+
("completed", "Completed"),
46+
("skipped", "Skipped"),
47+
],
48+
default="pending",
49+
help_text="Current status of the migration run",
50+
max_length=20,
51+
),
52+
),
53+
("created_at", models.DateTimeField(auto_now_add=True, help_text="When the migration run was created")),
54+
(
55+
"updated_at",
56+
models.DateTimeField(auto_now=True, help_text="When the migration run was last updated"),
57+
),
58+
(
59+
"completed_at",
60+
models.DateTimeField(blank=True, help_text="When the migration run was completed", null=True),
61+
),
62+
(
63+
"metadata",
64+
models.JSONField(
65+
blank=True,
66+
default=dict,
67+
help_text="Additional metadata about the migration run (e.g., counts, warnings)",
68+
),
69+
),
70+
],
71+
options={
72+
"verbose_name": "Course Authoring Migration Run",
73+
"verbose_name_plural": "Course Authoring Migration Runs",
74+
"ordering": ["-created_at"],
75+
"indexes": [
76+
models.Index(fields=["scope_type", "scope_key"], name="openedx_aut_scope_t_d43a35_idx"),
77+
models.Index(fields=["status"], name="openedx_aut_status_e34b60_idx"),
78+
models.Index(fields=["-created_at"], name="openedx_aut_created_ab3e0a_idx"),
79+
],
80+
},
81+
),
82+
]

openedx_authz/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@
1616
"""
1717

1818
from openedx_authz.models.core import *
19+
from openedx_authz.models.migrations import *
1920
from openedx_authz.models.scopes import *
2021
from openedx_authz.models.subjects import *

openedx_authz/models/migrations.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Models for tracking migration runs between legacy and AuthZ systems.
2+
3+
.. no_pii:
4+
"""
5+
6+
from django.db import models
7+
from django.utils import timezone
8+
9+
10+
class MigrationType(models.TextChoices):
11+
"""Direction of migration."""
12+
13+
FORWARD = "forward", "Legacy to AuthZ"
14+
ROLLBACK = "rollback", "AuthZ to Legacy"
15+
16+
17+
class Status(models.TextChoices):
18+
"""Status of the migration task."""
19+
20+
PENDING = "pending", "Pending"
21+
RUNNING = "running", "Running"
22+
COMPLETED = "completed", "Completed"
23+
SKIPPED = "skipped", "Skipped"
24+
25+
26+
class ScopeType(models.TextChoices):
27+
"""Type of scope being migrated."""
28+
29+
COURSE = "course", "Course"
30+
ORG = "org", "Organization"
31+
32+
33+
class AuthzCourseAuthoringMigrationRun(models.Model):
34+
"""Track the status of course authoring migration tasks.
35+
36+
This model is used to track async migrations between the legacy
37+
CourseAccessRole system and the new AuthZ system.
38+
"""
39+
40+
migration_type = models.CharField(
41+
max_length=20,
42+
choices=MigrationType,
43+
help_text="Direction of migration: forward (legacy → authz) or rollback (authz → legacy)",
44+
)
45+
46+
scope_type = models.CharField(
47+
max_length=20,
48+
choices=ScopeType,
49+
help_text="Type of scope being migrated: course or organization",
50+
)
51+
52+
scope_key = models.CharField(
53+
max_length=255,
54+
help_text="Identifier for the scope (e.g., course-v1:edX+DemoX+DemoCourse or org name)",
55+
)
56+
57+
status = models.CharField(
58+
max_length=20,
59+
choices=Status,
60+
default=Status.PENDING,
61+
help_text="Current status of the migration run",
62+
)
63+
64+
created_at = models.DateTimeField(
65+
auto_now_add=True,
66+
help_text="When the migration run was created",
67+
)
68+
69+
updated_at = models.DateTimeField(
70+
auto_now=True,
71+
help_text="When the migration run was last updated",
72+
)
73+
74+
completed_at = models.DateTimeField(
75+
null=True,
76+
blank=True,
77+
help_text="When the migration run was completed",
78+
)
79+
80+
metadata = models.JSONField(
81+
default=dict,
82+
blank=True,
83+
help_text="Additional metadata about the migration run (e.g., counts, warnings)",
84+
)
85+
86+
class Meta:
87+
verbose_name = "Course Authoring Migration Run"
88+
verbose_name_plural = "Course Authoring Migration Runs"
89+
ordering = ["-created_at"]
90+
indexes = [
91+
models.Index(fields=["scope_type", "scope_key"]),
92+
models.Index(fields=["status"]),
93+
models.Index(fields=["-created_at"]),
94+
]
95+
96+
@classmethod
97+
def create_pending(cls, migration_type, scope_type, scope_key, metadata=None) -> "AuthzCourseAuthoringMigrationRun":
98+
"""Create a pending migration run."""
99+
return cls.objects.create(
100+
migration_type=migration_type,
101+
scope_type=scope_type,
102+
scope_key=scope_key,
103+
metadata=metadata or {},
104+
)
105+
106+
def mark_running(self) -> None:
107+
"""Mark the migration run as running."""
108+
self.status = Status.RUNNING
109+
self.save(update_fields=["status", "updated_at"])
110+
111+
def mark_skipped(self, *, reason=None) -> None:
112+
"""Mark the migration run as skipped."""
113+
self.status = Status.SKIPPED
114+
if reason:
115+
self.metadata = {**(self.metadata or {}), "skip_reason": reason}
116+
self.save(update_fields=["status", "updated_at", "metadata"])
117+
return
118+
self.save(update_fields=["status", "updated_at"])
119+
120+
def mark_completed(self, *, metadata_updates=None) -> None:
121+
"""Mark the migration run as completed."""
122+
self.status = Status.COMPLETED
123+
self.completed_at = timezone.now()
124+
if metadata_updates:
125+
self.metadata = {**(self.metadata or {}), **metadata_updates}
126+
self.save(update_fields=["status", "completed_at", "updated_at", "metadata"])
127+
128+
def __str__(self) -> str:
129+
"""Return a string representation of the migration run."""
130+
return f"[{self.id}] {self.migration_type} {self.scope_type}:{self.scope_key} {self.status}"

openedx_authz/settings/common.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,8 @@ def plugin_settings(settings):
5454
# This setting defines the logging level for the Casbin enforcer.
5555
if not hasattr(settings, "CASBIN_LOG_LEVEL"):
5656
settings.CASBIN_LOG_LEVEL = "WARNING"
57+
58+
# Set default ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION if not already set.
59+
# This setting defines whether to enable automatic course migration.
60+
if not hasattr(settings, "ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION"):
61+
settings.ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION = False

openedx_authz/settings/test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,6 @@ def plugin_settings(settings): # pylint: disable=unused-argument
7878
# Use stub model for testing instead of the real content_libraries app
7979
OPENEDX_AUTHZ_CONTENT_LIBRARY_MODEL = "stubs.ContentLibrary"
8080
OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL = "stubs.CourseOverview"
81+
82+
# Migration settings
83+
ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION = False

0 commit comments

Comments
 (0)