Skip to content

Commit 639be47

Browse files
committed
feat: implement course authoring migration functionality
1 parent 1ff2152 commit 639be47

10 files changed

Lines changed: 631 additions & 45 deletions

File tree

openedx_authz/admin.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
11
"""Admin configuration for openedx_authz."""
22

3+
import json
4+
35
from casbin_adapter.models import CasbinRule
46
from django import forms
57
from django.contrib import admin
8+
from django.utils.html import format_html
9+
10+
from openedx_authz.models import AuthzCourseAuthoringMigrationRun, ExtendedCasbinRule
611

7-
from openedx_authz.models import ExtendedCasbinRule
12+
13+
def pretty_json(value) -> str:
14+
"""Return an indented JSON representation of a value."""
15+
if value is None:
16+
return "-"
17+
try:
18+
formatted = json.dumps(value, indent=2, ensure_ascii=False)
19+
except (TypeError, ValueError):
20+
return str(value)
21+
return format_html("<pre>{}</pre>", formatted)
822

923

1024
class CasbinRuleForm(forms.ModelForm):
@@ -48,3 +62,28 @@ class CasbinRuleAdmin(admin.ModelAdmin):
4862
# TODO: In a future, possibly we should only show an inline for the rules that
4963
# have an extended rule, and show the subject and scope information in detail.
5064
inlines = [ExtendedCasbinRuleInline]
65+
66+
67+
@admin.register(AuthzCourseAuthoringMigrationRun)
68+
class AuthzCourseAuthoringMigrationRunAdmin(admin.ModelAdmin):
69+
"""Admin for AuthzCourseAuthoringMigrationRun to display additional metadata."""
70+
71+
list_display = ("id", "scope_type", "scope_key", "migration_type", "status", "created_at", "updated_at")
72+
search_fields = ("scope_type", "scope_key", "migration_type", "status")
73+
list_filter = ("scope_type", "migration_type", "status")
74+
readonly_fields = (
75+
"scope_type",
76+
"scope_key",
77+
"migration_type",
78+
"status",
79+
"pretty_metadata",
80+
"completed_at",
81+
"created_at",
82+
"updated_at",
83+
)
84+
fields = readonly_fields
85+
86+
@admin.display(description="Metadata")
87+
def pretty_metadata(self, obj):
88+
"""Return formatted JSON for the metadata field."""
89+
return pretty_json(obj.metadata)

openedx_authz/api/roles.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,5 @@ def get_all_role_assignments_per_scope_type(scope_types: tuple[type[ScopeData],
571571
list[RoleAssignmentData]: All assignments whose scope is an instance of any of the given scope types.
572572
"""
573573
return [
574-
role_assignment for role_assignment in get_role_assignments()
575-
if isinstance(role_assignment.scope, scope_types)
574+
role_assignment for role_assignment in get_role_assignments() if isinstance(role_assignment.scope, scope_types)
576575
]

openedx_authz/engine/utils.py

Lines changed: 204 additions & 29 deletions
Large diffs are not rendered by default.

openedx_authz/handlers.py

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,33 @@
44
These handlers ensure proper cleanup and consistency when models are deleted.
55
"""
66

7+
from __future__ import annotations
8+
79
import logging
810

911
from casbin_adapter.models import CasbinRule
10-
from django.db.models.signals import post_delete
12+
from django.conf import settings
13+
from django.db.models.signals import post_delete, post_save
1114
from django.dispatch import receiver
1215

1316
from openedx_authz.api.users import unassign_all_roles_from_user
17+
from openedx_authz.engine.utils import run_course_authoring_migration
1418
from openedx_authz.models.core import ExtendedCasbinRule
19+
from openedx_authz.models.migrations import MigrationType, ScopeType
20+
from openedx_authz.models.subjects import UserSubject
1521

1622
try:
23+
from common.djangoapps.student.models import CourseAccessRole
1724
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL
25+
from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel, WaffleFlagOrgOverrideModel
26+
from openedx.core.toggles import AUTHZ_COURSE_AUTHORING_FLAG
1827
except ImportError:
1928
USER_RETIRE_LMS_CRITICAL = None
29+
WaffleFlagCourseOverrideModel = None
30+
WaffleFlagOrgOverrideModel = None
31+
AUTHZ_COURSE_AUTHORING_FLAG = None
32+
CourseAccessRole = None
33+
2034

2135
logger = logging.getLogger(__name__)
2236

@@ -82,3 +96,99 @@ def unassign_roles_on_user_retirement(sender, user, **kwargs): # pylint: disabl
8296
# Only register the handler if the signal is available (i.e., running in Open edX)
8397
if USER_RETIRE_LMS_CRITICAL is not None:
8498
USER_RETIRE_LMS_CRITICAL.connect(unassign_roles_on_user_retirement)
99+
100+
101+
def handle_course_waffle_flag_change(sender, instance, **kwargs) -> None:
102+
"""
103+
Handle changes to course-level waffle flags.
104+
105+
When the authz.enable_course_authoring flag is changed for a course,
106+
trigger the appropriate migration run. Only trigger if automatic migration
107+
is enabled in the settings.
108+
109+
Args:
110+
sender: The model class (WaffleFlagCourseOverrideModel)
111+
instance: The flag override instance being saved
112+
**kwargs: Additional keyword arguments from the signal
113+
"""
114+
trigger_course_authoring_migration(sender=sender, instance=instance, scope_key=str(instance.course_id))
115+
116+
117+
def handle_org_waffle_flag_change(sender, instance, **kwargs) -> None:
118+
"""
119+
Handle changes to organization-level waffle flags.
120+
121+
When the authz.enable_course_authoring flag is changed for an organization,
122+
trigger the appropriate migration run. Only trigger if automatic migration
123+
is enabled in the settings.
124+
125+
Args:
126+
sender: The model class (WaffleFlagOrgOverrideModel)
127+
instance: The flag override instance being saved
128+
**kwargs: Additional keyword arguments from the signal
129+
"""
130+
trigger_course_authoring_migration(sender=sender, instance=instance, scope_key=str(instance.org))
131+
132+
133+
# Only register the handlers if the models are available (i.e., running in Open edX)
134+
if WaffleFlagCourseOverrideModel is not None:
135+
post_save.connect(handle_course_waffle_flag_change, sender=WaffleFlagCourseOverrideModel)
136+
137+
if WaffleFlagOrgOverrideModel is not None:
138+
post_save.connect(handle_org_waffle_flag_change, sender=WaffleFlagOrgOverrideModel)
139+
140+
141+
def trigger_course_authoring_migration(
142+
sender: type[WaffleFlagCourseOverrideModel | WaffleFlagOrgOverrideModel],
143+
instance: WaffleFlagCourseOverrideModel | WaffleFlagOrgOverrideModel,
144+
scope_key: str,
145+
) -> None:
146+
"""
147+
Trigger a migration run in response to a waffle flag change.
148+
149+
Determines the migration direction from the flag state, guards against
150+
no-op saves, and delegates execution to ``run_course_authoring_migration``
151+
which handles tracking and concurrent-run protection.
152+
153+
Args:
154+
sender: The model class (WaffleFlagCourseOverrideModel or WaffleFlagOrgOverrideModel).
155+
instance: The waffle flag instance that triggered the migration.
156+
scope_key (str): Course ID or organization name.
157+
"""
158+
if not settings.ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION:
159+
logger.info("ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION is set to False, skipping migration")
160+
return
161+
162+
if instance.waffle_flag != AUTHZ_COURSE_AUTHORING_FLAG.name:
163+
return
164+
165+
course_id_list, org_id, scope_type = None, None, None
166+
filter_kwargs = {"waffle_flag": AUTHZ_COURSE_AUTHORING_FLAG.name}
167+
if isinstance(instance, WaffleFlagCourseOverrideModel):
168+
filter_kwargs["course_id"] = instance.course_id
169+
course_id_list = [scope_key]
170+
scope_type = ScopeType.COURSE
171+
elif isinstance(instance, WaffleFlagOrgOverrideModel):
172+
filter_kwargs["org"] = instance.org
173+
org_id = scope_key
174+
scope_type = ScopeType.ORG
175+
176+
prev_record = sender.objects.filter(**filter_kwargs).exclude(id=instance.id).order_by("-change_date").first()
177+
178+
if prev_record and prev_record.enabled == instance.enabled:
179+
logger.info("No change in waffle flag, skipping course migration")
180+
return
181+
182+
migration_type = MigrationType.FORWARD if instance.enabled else MigrationType.ROLLBACK
183+
184+
logger.info("Triggering %s migration for %s:%s due to waffle flag change", migration_type, scope_type, scope_key)
185+
186+
run_course_authoring_migration(
187+
migration_type=migration_type,
188+
scope_type=scope_type,
189+
scope_key=scope_key,
190+
course_access_role_model=CourseAccessRole,
191+
user_subject_model=UserSubject,
192+
course_id_list=course_id_list,
193+
org_id=org_id,
194+
)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Generated by Django 5.2.12 on 2026-04-14 20:21
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+
("running", "Running"),
44+
("completed", "Completed"),
45+
("partial_success", "Partial Success"),
46+
("failed", "Failed"),
47+
("skipped", "Skipped"),
48+
],
49+
default="running",
50+
help_text="Current status of the migration run",
51+
max_length=20,
52+
),
53+
),
54+
("created_at", models.DateTimeField(auto_now_add=True, help_text="When the migration run was created")),
55+
(
56+
"updated_at",
57+
models.DateTimeField(auto_now=True, help_text="When the migration run was last updated"),
58+
),
59+
(
60+
"completed_at",
61+
models.DateTimeField(blank=True, help_text="When the migration run was completed", null=True),
62+
),
63+
(
64+
"metadata",
65+
models.JSONField(
66+
blank=True,
67+
default=dict,
68+
help_text="Additional metadata about the migration run (e.g., counts, warnings, errors)",
69+
),
70+
),
71+
],
72+
options={
73+
"verbose_name": "Course Authoring Migration Run",
74+
"verbose_name_plural": "Course Authoring Migration Runs",
75+
"ordering": ["-created_at"],
76+
"indexes": [
77+
models.Index(fields=["scope_type", "scope_key"], name="openedx_aut_scope_t_d43a35_idx"),
78+
models.Index(fields=["status"], name="openedx_aut_status_e34b60_idx"),
79+
models.Index(fields=["-created_at"], name="openedx_aut_created_ab3e0a_idx"),
80+
],
81+
},
82+
),
83+
]

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 *

0 commit comments

Comments
 (0)