Skip to content

Commit aa773f4

Browse files
author
Tycho Hob
committed
feat: Add CCX concepts
No permissions are granted for CCX courses, this is just a placeholder to keep these courses from erroring until we get to the LMS side
1 parent ef8b1d1 commit aa773f4

12 files changed

Lines changed: 119 additions & 11 deletions

File tree

openedx_authz/api/data.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,30 @@ class OrgCourseOverviewGlobData(OrgGlobData):
838838

839839
NAMESPACE: ClassVar[str] = "course-v1"
840840
ID_SEPARATOR: ClassVar[str] = "+"
841+
842+
843+
class CCXCourseOverviewData(CourseOverviewData):
844+
"""CCX course scope for authorization in the Open edX platform.
845+
846+
Inherits from CourseOverviewData as CCXs are coursees, 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 = CourseOverviewData(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"
841865

842866

843867
class SubjectMeta(type):

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: 19 additions & 8 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([course_key for course_key in course_id_list if not course_key.startswith("course-v1:")]):
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
}
@@ -280,10 +294,7 @@ def migrate_authz_to_legacy_course_roles(
280294
param delete_after_migration: Whether to unassign successfully migrated permissions
281295
from the new model after migration.
282296
"""
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-
)
297+
_validate_migration_input(course_id_list, org_id)
287298

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

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),

openedx_authz/tests/test_migrations.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ def setUp(self):
196196
"org": self.org,
197197
"course_id": self.course_id,
198198
}
199+
self.invalid_course = f"ccx-v1:{self.org}+{OBJECT_PREFIX}+2026_01+ccx@2"
199200
self.course_overview = CourseOverview.objects.create(
200201
id=self.course_id, org=self.org, display_name=f"{OBJECT_PREFIX} Course"
201202
)
@@ -883,6 +884,17 @@ def test_migrate_authz_to_legacy_course_roles_with_no_org_and_courses(self):
883884
CourseAccessRole, UserSubject, course_id_list=None, org_id=None, delete_after_migration=True
884885
)
885886

887+
@patch("openedx_authz.api.data.CourseOverview", CourseOverview)
888+
def test_migrate_authz_to_legacy_course_roles_with_invalid_courses(self):
889+
with self.assertRaises(ValueError):
890+
migrate_authz_to_legacy_course_roles(
891+
CourseAccessRole,
892+
UserSubject,
893+
course_id_list=[self.invalid_course],
894+
org_id=None,
895+
delete_after_migration=True,
896+
)
897+
886898
@patch("openedx_authz.api.data.CourseOverview", CourseOverview)
887899
def test_migrate_legacy_course_roles_to_authz_with_no_org_and_courses(self):
888900
# Migrate from legacy CourseAccessRole to new Casbin-based model
@@ -891,6 +903,16 @@ def test_migrate_legacy_course_roles_to_authz_with_no_org_and_courses(self):
891903
CourseAccessRole, course_id_list=None, org_id=None, delete_after_migration=True
892904
)
893905

906+
@patch("openedx_authz.api.data.CourseOverview", CourseOverview)
907+
def test_migrate_legacy_course_roles_to_authz_with_invalid_courses(self):
908+
with self.assertRaises(ValueError):
909+
migrate_legacy_course_roles_to_authz(
910+
CourseAccessRole,
911+
course_id_list=[self.invalid_course],
912+
org_id=None,
913+
delete_after_migration=True,
914+
)
915+
894916
@patch("openedx_authz.management.commands.authz_migrate_course_authoring.CourseAccessRole", CourseAccessRole)
895917
@patch("openedx_authz.management.commands.authz_migrate_course_authoring.migrate_legacy_course_roles_to_authz")
896918
def test_authz_migrate_course_authoring_command(self, mock_migrate):

requirements/base.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ attrs # Classes without boilerplate
88
pycasbin # Authorization library for implementing access control models
99
casbin-django-orm-adapter # Adapter for Django ORM for Casbin
1010
edx-opaque-keys # Opaque keys for resource identification
11+
edx-ccx-keys # CCX keys for Custom Course identification
1112
edx-api-doc-tools # Tools for API documentation
1213
edx-django-utils # Used for RequestCache
1314
edx-drf-extensions # Extensions for Django Rest Framework used by Open edX

requirements/base.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ drf-yasg==1.21.11
6464
# via edx-api-doc-tools
6565
edx-api-doc-tools==2.1.2
6666
# via -r requirements/base.in
67+
edx-ccx-keys==2.0.2
68+
# via -r requirements/base.in
6769
edx-django-utils==8.0.1
6870
# via
6971
# -r requirements/base.in
@@ -75,6 +77,7 @@ edx-drf-extensions==10.6.0
7577
edx-opaque-keys==3.0.0
7678
# via
7779
# -r requirements/base.in
80+
# edx-ccx-keys
7881
# edx-drf-extensions
7982
# edx-organizations
8083
edx-organizations==7.3.0
@@ -115,6 +118,8 @@ semantic-version==2.10.0
115118
# via edx-drf-extensions
116119
simpleeval==1.0.3
117120
# via pycasbin
121+
six==1.17.0
122+
# via edx-ccx-keys
118123
sqlparse==0.5.3
119124
# via django
120125
stevedore==5.5.0

requirements/dev.txt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ astroid==4.0.3
1515
# pylint-celery
1616
attrs==25.3.0
1717
# via -r requirements/quality.txt
18-
build==1.4.0
18+
build==1.4.2
1919
# via
2020
# -r requirements/pip-tools.txt
2121
# pip-tools
@@ -140,6 +140,8 @@ drf-yasg==1.21.11
140140
# edx-api-doc-tools
141141
edx-api-doc-tools==2.1.2
142142
# via -r requirements/quality.txt
143+
edx-ccx-keys==2.0.2
144+
# via -r requirements/quality.txt
143145
edx-django-utils==8.0.1
144146
# via
145147
# -r requirements/quality.txt
@@ -155,6 +157,7 @@ edx-lint==5.6.0
155157
edx-opaque-keys==3.0.0
156158
# via
157159
# -r requirements/quality.txt
160+
# edx-ccx-keys
158161
# edx-drf-extensions
159162
# edx-organizations
160163
edx-organizations==7.3.0
@@ -338,6 +341,7 @@ simpleeval==1.0.3
338341
six==1.17.0
339342
# via
340343
# -r requirements/quality.txt
344+
# edx-ccx-keys
341345
# edx-lint
342346
snowballstemmer==3.0.1
343347
# via

requirements/doc.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ drf-yasg==1.21.11
119119
# edx-api-doc-tools
120120
edx-api-doc-tools==2.1.2
121121
# via -r requirements/test.txt
122+
edx-ccx-keys==2.0.2
123+
# via -r requirements/test.txt
122124
edx-django-utils==8.0.1
123125
# via
124126
# -r requirements/test.txt
@@ -130,6 +132,7 @@ edx-drf-extensions==10.6.0
130132
edx-opaque-keys==3.0.0
131133
# via
132134
# -r requirements/test.txt
135+
# edx-ccx-keys
133136
# edx-drf-extensions
134137
# edx-organizations
135138
edx-organizations==7.3.0
@@ -292,6 +295,10 @@ simpleeval==1.0.3
292295
# via
293296
# -r requirements/test.txt
294297
# pycasbin
298+
six==1.17.0
299+
# via
300+
# -r requirements/test.txt
301+
# edx-ccx-keys
295302
snowballstemmer==3.0.1
296303
# via sphinx
297304
soupsieve==2.8

requirements/pip-tools.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
#
55
# pip-compile --output-file=requirements/pip-tools.txt requirements/pip-tools.in
66
#
7-
build==1.4.0
7+
build==1.4.2
88
# via pip-tools
99
click==8.3.1
1010
# via pip-tools

0 commit comments

Comments
 (0)