Skip to content

Commit 9b3c787

Browse files
authored
Merge pull request #247 from openedx/bmtcril/handle_ccx
Add CCX concepts, fix errors around CCX
2 parents ef8b1d1 + 0184ddd commit 9b3c787

18 files changed

Lines changed: 250 additions & 21 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
matrix:
1616
os: [ubuntu-latest]
1717
python-version: ["3.12"]
18-
toxenv: [quality, docs, pii_check, django42, django52]
18+
toxenv: [quality, docs, pii_check, django52]
1919
steps:
2020
- uses: actions/checkout@v6
2121
- name: setup python
@@ -35,7 +35,7 @@ jobs:
3535
run: tox
3636

3737
- name: Run coverage
38-
if: matrix.python-version == '3.12' && matrix.toxenv == 'django42'
38+
if: matrix.python-version == '3.12' && matrix.toxenv == 'django52'
3939
uses: codecov/codecov-action@v6
4040
with:
4141
token: ${{ secrets.CODECOV_TOKEN }}

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ Change Log
1414
Unreleased
1515
**********
1616

17+
1.3.0 2026-04-08
18+
****************
19+
20+
* Add stub CCX_COACH role/ CCXCourseOverviewData scope to prevent errors when working with CCX courses.
1721
* Add ADR for global scope support for role assignments.
1822

1923
1.2.0 - 2026-03-30

openedx_authz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55
import os
66

7-
__version__ = "1.2.0"
7+
__version__ = "1.3.0"
88

99
ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

openedx_authz/api/data.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,30 @@ class OrgCourseOverviewGlobData(OrgGlobData):
840840
ID_SEPARATOR: ClassVar[str] = "+"
841841

842842

843+
class CCXCourseOverviewData(CourseOverviewData):
844+
"""CCX course scope for authorization in the Open edX platform.
845+
846+
Inherits from CourseOverviewData as CCXs are courses, just in a different namespace.
847+
848+
Attributes:
849+
NAMESPACE: 'ccx-v1' for course scopes.
850+
external_key: The course identifier (e.g., 'ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1').
851+
Must be a valid CourseKey format.
852+
namespaced_key: The course identifier with namespace (e.g., 'ccx-v1^ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1').
853+
course_id: Property alias for external_key.
854+
855+
Examples:
856+
>>> course = CCXCourseOverviewData(external_key='ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1')
857+
>>> course.namespaced_key
858+
'ccx-v1^ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1'
859+
>>> course.course_id
860+
'ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1'
861+
862+
"""
863+
864+
NAMESPACE: ClassVar[str] = "ccx-v1"
865+
866+
843867
class SubjectMeta(type):
844868
"""Metaclass for SubjectData to handle dynamic subclass instantiation based on namespace."""
845869

openedx_authz/constants/roles.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,13 @@
180180

181181
COURSE_BETA_TESTER = RoleData(external_key="course_beta_tester", permissions=COURSE_BETA_TESTER_PERMISSIONS)
182182

183+
# This is a known LMS-only permission, but doesn't actually grant anything yet.
184+
#
185+
# It is intended to be handled in the Willow time frame.
186+
CCX_COACH_PERMISSIONS = []
187+
CCX_COACH = RoleData(external_key="ccx_coach", permissions=CCX_COACH_PERMISSIONS)
188+
189+
183190
# Map of legacy course role names to their equivalent new roles
184191
# This mapping must be unique in both directions, since it may be used as a reverse lookup (value → key).
185192
# If multiple keys share the same value, it will lead to collisions.
@@ -189,4 +196,5 @@
189196
"limited_staff": COURSE_LIMITED_STAFF.external_key,
190197
"data_researcher": COURSE_DATA_RESEARCHER.external_key,
191198
"beta_testers": COURSE_BETA_TESTER.external_key,
199+
"ccx_coach": CCX_COACH.external_key,
192200
}

openedx_authz/engine/utils.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,22 @@ def migrate_legacy_permissions(ContentLibraryPermission):
169169
return permissions_with_errors
170170

171171

172+
def _validate_migration_input(course_id_list, org_id):
173+
"""
174+
Validate the common inputs for the migration functions.
175+
"""
176+
if not course_id_list and not org_id:
177+
raise ValueError(
178+
"At least one of course_id_list or org_id must be provided to limit the scope of the migration."
179+
)
180+
181+
if course_id_list and any(not course_key.startswith("course-v1:") for course_key in course_id_list):
182+
raise ValueError(
183+
"Only full course keys (e.g., 'course-v1:org+course+run') are supported in the course_id_list."
184+
" Other course types such as CCX are not supported."
185+
)
186+
187+
172188
def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_list, org_id, delete_after_migration):
173189
"""
174190
Migrate legacy course role data to the new Casbin-based authorization model.
@@ -194,10 +210,8 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis
194210
param org_id: Optional organization ID to filter the migration.
195211
param delete_after_migration: Whether to delete successfully migrated legacy permissions after migration.
196212
"""
197-
if not course_id_list and not org_id:
198-
raise ValueError(
199-
"At least one of course_id_list or org_id must be provided to limit the scope of the migration."
200-
)
213+
_validate_migration_input(course_id_list, org_id)
214+
201215
course_access_role_filter = {
202216
"course_id__startswith": "course-v1:",
203217
}
@@ -244,7 +258,8 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis
244258
if not is_user_added:
245259
logger.error(
246260
f"Failed to migrate permission for User: {permission.user.username} "
247-
f"to Role: {role} in Scope: {permission.course_id}"
261+
f"to Role: {role} in Scope: {permission.course_id} "
262+
"user may already have this permission assigned"
248263
)
249264
permissions_with_errors.append(permission)
250265
continue
@@ -280,10 +295,7 @@ def migrate_authz_to_legacy_course_roles(
280295
param delete_after_migration: Whether to unassign successfully migrated permissions
281296
from the new model after migration.
282297
"""
283-
if not course_id_list and not org_id:
284-
raise ValueError(
285-
"At least one of course_id_list or org_id must be provided to limit the scope of the rollback migration."
286-
)
298+
_validate_migration_input(course_id_list, org_id)
287299

288300
# 1. Get all users with course-related permissions in the new model by filtering
289301
# UserSubjects that are linked to CourseScopes with a valid course overview.

openedx_authz/management/commands/authz_migrate_course_authoring.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,25 @@ def handle(self, *args, **options):
7070
delete_after_migration=delete_after_migration,
7171
)
7272

73-
if errors:
73+
if errors and success:
74+
self.stdout.write(
75+
self.style.WARNING(
76+
f"Migration completed with {len(errors)} errors and {len(success)} roles migrated."
77+
)
78+
)
79+
elif errors:
7480
self.stdout.write(self.style.ERROR(f"Migration completed with {len(errors)} errors."))
75-
else:
81+
elif success:
7682
self.stdout.write(
7783
self.style.SUCCESS(f"Migration completed successfully with {len(success)} roles migrated.")
7884
)
85+
else:
86+
self.stdout.write(
87+
self.style.ERROR(
88+
"No legacy roles found for the given scope, course could already be migrated, "
89+
"or there could be an error in the course_id_list / org_id."
90+
)
91+
)
7992

8093
if delete_after_migration:
8194
self.stdout.write(self.style.SUCCESS(f"{len(success)} Legacy roles deleted successfully."))

openedx_authz/management/commands/authz_rollback_course_authoring.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,25 @@ def handle(self, *args, **options):
7474
delete_after_migration=delete_after_migration, # control deletion here
7575
)
7676

77-
if errors:
77+
if errors and success:
78+
self.stdout.write(
79+
self.style.WARNING(
80+
f"Rollback completed with {len(errors)} errors and {len(success)} roles rolled back."
81+
)
82+
)
83+
elif errors:
7884
self.stdout.write(self.style.ERROR(f"Rollback completed with {len(errors)} errors."))
79-
else:
85+
elif success:
8086
self.stdout.write(
8187
self.style.SUCCESS(f"Rollback completed successfully with {len(success)} roles rolled back.")
8288
)
89+
else:
90+
self.stdout.write(
91+
self.style.ERROR(
92+
"No roles found for the given scope, course could already be rolled back, "
93+
"or there could be an error in the course_id_list / org_id."
94+
)
95+
)
8396

8497
if delete_after_migration:
8598
self.stdout.write(

openedx_authz/tests/api/test_data.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from openedx_authz.api.data import (
1010
ActionData,
11+
CCXCourseOverviewData,
1112
ContentLibraryData,
1213
CourseOverviewData,
1314
OrgContentLibraryGlobData,
@@ -257,6 +258,8 @@ def test_scope_data_registration(self):
257258
self.assertIs(ScopeData.scope_registry["lib"], ContentLibraryData)
258259
self.assertIn("course-v1", ScopeData.scope_registry)
259260
self.assertIs(ScopeData.scope_registry["course-v1"], CourseOverviewData)
261+
self.assertIn("ccx-v1", ScopeData.scope_registry)
262+
self.assertIs(ScopeData.scope_registry["ccx-v1"], CCXCourseOverviewData)
260263

261264
# Glob registries for organization-level scopes
262265
self.assertIn("lib", ScopeMeta.glob_registry)
@@ -265,6 +268,7 @@ def test_scope_data_registration(self):
265268
self.assertIs(ScopeMeta.glob_registry["course-v1"], OrgCourseOverviewGlobData)
266269

267270
@data(
271+
("ccx-v1^ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1", CCXCourseOverviewData),
268272
("course-v1^course-v1:WGU+CS002+2025_T1", CourseOverviewData),
269273
("lib^lib:DemoX:CSPROB", ContentLibraryData),
270274
("lib^lib:DemoX*", OrgContentLibraryGlobData),
@@ -285,6 +289,7 @@ def test_dynamic_instantiation_via_namespaced_key(self, namespaced_key, expected
285289
self.assertEqual(instance.namespaced_key, namespaced_key)
286290

287291
@data(
292+
("ccx-v1^ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1", CCXCourseOverviewData),
288293
("course-v1^course-v1:WGU+CS002+2025_T1", CourseOverviewData),
289294
("lib^lib:DemoX:CSPROB", ContentLibraryData),
290295
("lib^lib:DemoX:*", OrgContentLibraryGlobData),
@@ -297,6 +302,8 @@ def test_get_subclass_by_namespaced_key(self, namespaced_key, expected_class):
297302
"""Test get_subclass_by_namespaced_key returns correct subclass.
298303
299304
Expected Result:
305+
- 'ccx-v1^...' returns CCXCourseOverviewData
306+
- 'course-v1^...' returns CourseOverviewData
300307
- 'lib^...' returns ContentLibraryData
301308
- 'global^...' returns ScopeData
302309
- 'unknown^...' returns ScopeData (fallback)
@@ -306,6 +313,7 @@ def test_get_subclass_by_namespaced_key(self, namespaced_key, expected_class):
306313
self.assertIs(subclass, expected_class)
307314

308315
@data(
316+
("ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1", CCXCourseOverviewData),
309317
("course-v1:WGU+CS002+2025_T1", CourseOverviewData),
310318
("lib:DemoX:CSPROB", ContentLibraryData),
311319
("lib:DemoX:*", OrgContentLibraryGlobData),
@@ -326,6 +334,11 @@ def test_get_subclass_by_external_key(self, external_key, expected_class):
326334
self.assertIs(subclass, expected_class)
327335

328336
@data(
337+
("ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1", True, CCXCourseOverviewData),
338+
("ccx:OpenedX+DemoX+DemoCourse+ccx@1", False, CCXCourseOverviewData),
339+
("ccx-v2:OpenedX+DemoX+DemoCourse+ccx@1", False, CCXCourseOverviewData),
340+
("ccx-v1-OpenedX+DemoX+DemoCourse+ccx@1", False, CCXCourseOverviewData),
341+
("ccx-v1-OpenedX+DemoX+DemoCourse+ccx", False, CCXCourseOverviewData),
329342
("course-v1:WGU+CS002+2025_T1", True, CourseOverviewData),
330343
("course:WGU+CS002+2025_T1", False, CourseOverviewData),
331344
("course-v2:WGU+CS002+2025_T1", False, CourseOverviewData),

0 commit comments

Comments
 (0)