From aa773f4670b559b44d71c0d6328c178a90de832f Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Thu, 2 Apr 2026 09:58:24 -0400 Subject: [PATCH 1/6] 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 --- openedx_authz/api/data.py | 24 +++++++++++++++++++++++ openedx_authz/constants/roles.py | 8 ++++++++ openedx_authz/engine/utils.py | 27 ++++++++++++++++++-------- openedx_authz/tests/api/test_data.py | 13 +++++++++++++ openedx_authz/tests/test_migrations.py | 22 +++++++++++++++++++++ requirements/base.in | 1 + requirements/base.txt | 5 +++++ requirements/dev.txt | 6 +++++- requirements/doc.txt | 7 +++++++ requirements/pip-tools.txt | 2 +- requirements/quality.txt | 8 +++++++- requirements/test.txt | 7 +++++++ 12 files changed, 119 insertions(+), 11 deletions(-) diff --git a/openedx_authz/api/data.py b/openedx_authz/api/data.py index ee42d073..9adb6179 100644 --- a/openedx_authz/api/data.py +++ b/openedx_authz/api/data.py @@ -838,6 +838,30 @@ class OrgCourseOverviewGlobData(OrgGlobData): NAMESPACE: ClassVar[str] = "course-v1" ID_SEPARATOR: ClassVar[str] = "+" + + +class CCXCourseOverviewData(CourseOverviewData): + """CCX course scope for authorization in the Open edX platform. + + Inherits from CourseOverviewData as CCXs are coursees, just in a different namespace. + + Attributes: + NAMESPACE: 'ccx-v1' for course scopes. + external_key: The course identifier (e.g., 'ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1'). + Must be a valid CourseKey format. + namespaced_key: The course identifier with namespace (e.g., 'ccx-v1^ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1'). + course_id: Property alias for external_key. + + Examples: + >>> course = CourseOverviewData(external_key='ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1') + >>> course.namespaced_key + 'ccx-v1^ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1' + >>> course.course_id + 'ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1' + + """ + + NAMESPACE: ClassVar[str] = "ccx-v1" class SubjectMeta(type): diff --git a/openedx_authz/constants/roles.py b/openedx_authz/constants/roles.py index 4ac6f941..17971ba4 100644 --- a/openedx_authz/constants/roles.py +++ b/openedx_authz/constants/roles.py @@ -180,6 +180,13 @@ COURSE_BETA_TESTER = RoleData(external_key="course_beta_tester", permissions=COURSE_BETA_TESTER_PERMISSIONS) +# This is a known LMS-only permission, but doesn't actually grant anything yet. +# +# It is intended to be handled in the Willow time frame. +CCX_COACH_PERMISSIONS = [] +CCX_COACH = RoleData(external_key="ccx_coach", permissions=CCX_COACH_PERMISSIONS) + + # Map of legacy course role names to their equivalent new roles # This mapping must be unique in both directions, since it may be used as a reverse lookup (value → key). # If multiple keys share the same value, it will lead to collisions. @@ -189,4 +196,5 @@ "limited_staff": COURSE_LIMITED_STAFF.external_key, "data_researcher": COURSE_DATA_RESEARCHER.external_key, "beta_testers": COURSE_BETA_TESTER.external_key, + "ccx_coach": CCX_COACH.external_key, } diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py index 8823d4c6..c86dd244 100644 --- a/openedx_authz/engine/utils.py +++ b/openedx_authz/engine/utils.py @@ -169,6 +169,22 @@ def migrate_legacy_permissions(ContentLibraryPermission): return permissions_with_errors +def _validate_migration_input(course_id_list, org_id): + """ + Validate the common inputs for the migration functions. + """ + if not course_id_list and not org_id: + raise ValueError( + "At least one of course_id_list or org_id must be provided to limit the scope of the migration." + ) + + if course_id_list and any([course_key for course_key in course_id_list if not course_key.startswith("course-v1:")]): + raise ValueError( + "Only full course keys (e.g., 'course-v1:org+course+run') are supported in the course_id_list." + " Other course types such as CCX are not supported." + ) + + def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_list, org_id, delete_after_migration): """ 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 param org_id: Optional organization ID to filter the migration. param delete_after_migration: Whether to delete successfully migrated legacy permissions after migration. """ - if not course_id_list and not org_id: - raise ValueError( - "At least one of course_id_list or org_id must be provided to limit the scope of the migration." - ) + _validate_migration_input(course_id_list, org_id) + course_access_role_filter = { "course_id__startswith": "course-v1:", } @@ -280,10 +294,7 @@ def migrate_authz_to_legacy_course_roles( param delete_after_migration: Whether to unassign successfully migrated permissions from the new model after migration. """ - if not course_id_list and not org_id: - raise ValueError( - "At least one of course_id_list or org_id must be provided to limit the scope of the rollback migration." - ) + _validate_migration_input(course_id_list, org_id) # 1. Get all users with course-related permissions in the new model by filtering # UserSubjects that are linked to CourseScopes with a valid course overview. diff --git a/openedx_authz/tests/api/test_data.py b/openedx_authz/tests/api/test_data.py index 12fccbc4..49b11eb8 100644 --- a/openedx_authz/tests/api/test_data.py +++ b/openedx_authz/tests/api/test_data.py @@ -8,6 +8,7 @@ from openedx_authz.api.data import ( ActionData, + CCXCourseOverviewData, ContentLibraryData, CourseOverviewData, OrgContentLibraryGlobData, @@ -257,6 +258,8 @@ def test_scope_data_registration(self): self.assertIs(ScopeData.scope_registry["lib"], ContentLibraryData) self.assertIn("course-v1", ScopeData.scope_registry) self.assertIs(ScopeData.scope_registry["course-v1"], CourseOverviewData) + self.assertIn("ccx-v1", ScopeData.scope_registry) + self.assertIs(ScopeData.scope_registry["ccx-v1"], CCXCourseOverviewData) # Glob registries for organization-level scopes self.assertIn("lib", ScopeMeta.glob_registry) @@ -265,6 +268,7 @@ def test_scope_data_registration(self): self.assertIs(ScopeMeta.glob_registry["course-v1"], OrgCourseOverviewGlobData) @data( + ("ccx-v1^ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1", CCXCourseOverviewData), ("course-v1^course-v1:WGU+CS002+2025_T1", CourseOverviewData), ("lib^lib:DemoX:CSPROB", ContentLibraryData), ("lib^lib:DemoX*", OrgContentLibraryGlobData), @@ -285,6 +289,7 @@ def test_dynamic_instantiation_via_namespaced_key(self, namespaced_key, expected self.assertEqual(instance.namespaced_key, namespaced_key) @data( + ("ccx-v1^ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1", CCXCourseOverviewData), ("course-v1^course-v1:WGU+CS002+2025_T1", CourseOverviewData), ("lib^lib:DemoX:CSPROB", ContentLibraryData), ("lib^lib:DemoX:*", OrgContentLibraryGlobData), @@ -297,6 +302,8 @@ def test_get_subclass_by_namespaced_key(self, namespaced_key, expected_class): """Test get_subclass_by_namespaced_key returns correct subclass. Expected Result: + - 'ccx-v1^...' returns CCXCourseOverviewData + - 'course-v1^...' returns CourseOverviewData - 'lib^...' returns ContentLibraryData - 'global^...' returns ScopeData - 'unknown^...' returns ScopeData (fallback) @@ -306,6 +313,7 @@ def test_get_subclass_by_namespaced_key(self, namespaced_key, expected_class): self.assertIs(subclass, expected_class) @data( + ("ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1", CCXCourseOverviewData), ("course-v1:WGU+CS002+2025_T1", CourseOverviewData), ("lib:DemoX:CSPROB", ContentLibraryData), ("lib:DemoX:*", OrgContentLibraryGlobData), @@ -326,6 +334,11 @@ def test_get_subclass_by_external_key(self, external_key, expected_class): self.assertIs(subclass, expected_class) @data( + ("ccx-v1:OpenedX+DemoX+DemoCourse+ccx@1", True, CCXCourseOverviewData), + ("ccx:OpenedX+DemoX+DemoCourse+ccx@1", False, CCXCourseOverviewData), + ("ccx-v2:OpenedX+DemoX+DemoCourse+ccx@1", False, CCXCourseOverviewData), + ("ccx-v1-OpenedX+DemoX+DemoCourse+ccx@1", False, CCXCourseOverviewData), + ("ccx-v1-OpenedX+DemoX+DemoCourse+ccx", False, CCXCourseOverviewData), ("course-v1:WGU+CS002+2025_T1", True, CourseOverviewData), ("course:WGU+CS002+2025_T1", False, CourseOverviewData), ("course-v2:WGU+CS002+2025_T1", False, CourseOverviewData), diff --git a/openedx_authz/tests/test_migrations.py b/openedx_authz/tests/test_migrations.py index 795ccbba..07a98d51 100644 --- a/openedx_authz/tests/test_migrations.py +++ b/openedx_authz/tests/test_migrations.py @@ -196,6 +196,7 @@ def setUp(self): "org": self.org, "course_id": self.course_id, } + self.invalid_course = f"ccx-v1:{self.org}+{OBJECT_PREFIX}+2026_01+ccx@2" self.course_overview = CourseOverview.objects.create( id=self.course_id, org=self.org, display_name=f"{OBJECT_PREFIX} Course" ) @@ -883,6 +884,17 @@ def test_migrate_authz_to_legacy_course_roles_with_no_org_and_courses(self): CourseAccessRole, UserSubject, course_id_list=None, org_id=None, delete_after_migration=True ) + @patch("openedx_authz.api.data.CourseOverview", CourseOverview) + def test_migrate_authz_to_legacy_course_roles_with_invalid_courses(self): + with self.assertRaises(ValueError): + migrate_authz_to_legacy_course_roles( + CourseAccessRole, + UserSubject, + course_id_list=[self.invalid_course], + org_id=None, + delete_after_migration=True, + ) + @patch("openedx_authz.api.data.CourseOverview", CourseOverview) def test_migrate_legacy_course_roles_to_authz_with_no_org_and_courses(self): # 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): CourseAccessRole, course_id_list=None, org_id=None, delete_after_migration=True ) + @patch("openedx_authz.api.data.CourseOverview", CourseOverview) + def test_migrate_legacy_course_roles_to_authz_with_invalid_courses(self): + with self.assertRaises(ValueError): + migrate_legacy_course_roles_to_authz( + CourseAccessRole, + course_id_list=[self.invalid_course], + org_id=None, + delete_after_migration=True, + ) + @patch("openedx_authz.management.commands.authz_migrate_course_authoring.CourseAccessRole", CourseAccessRole) @patch("openedx_authz.management.commands.authz_migrate_course_authoring.migrate_legacy_course_roles_to_authz") def test_authz_migrate_course_authoring_command(self, mock_migrate): diff --git a/requirements/base.in b/requirements/base.in index b3cc7793..99d7082a 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -8,6 +8,7 @@ attrs # Classes without boilerplate pycasbin # Authorization library for implementing access control models casbin-django-orm-adapter # Adapter for Django ORM for Casbin edx-opaque-keys # Opaque keys for resource identification +edx-ccx-keys # CCX keys for Custom Course identification edx-api-doc-tools # Tools for API documentation edx-django-utils # Used for RequestCache edx-drf-extensions # Extensions for Django Rest Framework used by Open edX diff --git a/requirements/base.txt b/requirements/base.txt index 7fc673d2..e9d01251 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -64,6 +64,8 @@ drf-yasg==1.21.11 # via edx-api-doc-tools edx-api-doc-tools==2.1.2 # via -r requirements/base.in +edx-ccx-keys==2.0.2 + # via -r requirements/base.in edx-django-utils==8.0.1 # via # -r requirements/base.in @@ -75,6 +77,7 @@ edx-drf-extensions==10.6.0 edx-opaque-keys==3.0.0 # via # -r requirements/base.in + # edx-ccx-keys # edx-drf-extensions # edx-organizations edx-organizations==7.3.0 @@ -115,6 +118,8 @@ semantic-version==2.10.0 # via edx-drf-extensions simpleeval==1.0.3 # via pycasbin +six==1.17.0 + # via edx-ccx-keys sqlparse==0.5.3 # via django stevedore==5.5.0 diff --git a/requirements/dev.txt b/requirements/dev.txt index 4c854827..b84f90f9 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -15,7 +15,7 @@ astroid==4.0.3 # pylint-celery attrs==25.3.0 # via -r requirements/quality.txt -build==1.4.0 +build==1.4.2 # via # -r requirements/pip-tools.txt # pip-tools @@ -140,6 +140,8 @@ drf-yasg==1.21.11 # edx-api-doc-tools edx-api-doc-tools==2.1.2 # via -r requirements/quality.txt +edx-ccx-keys==2.0.2 + # via -r requirements/quality.txt edx-django-utils==8.0.1 # via # -r requirements/quality.txt @@ -155,6 +157,7 @@ edx-lint==5.6.0 edx-opaque-keys==3.0.0 # via # -r requirements/quality.txt + # edx-ccx-keys # edx-drf-extensions # edx-organizations edx-organizations==7.3.0 @@ -338,6 +341,7 @@ simpleeval==1.0.3 six==1.17.0 # via # -r requirements/quality.txt + # edx-ccx-keys # edx-lint snowballstemmer==3.0.1 # via diff --git a/requirements/doc.txt b/requirements/doc.txt index c46333f7..5d74555a 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -119,6 +119,8 @@ drf-yasg==1.21.11 # edx-api-doc-tools edx-api-doc-tools==2.1.2 # via -r requirements/test.txt +edx-ccx-keys==2.0.2 + # via -r requirements/test.txt edx-django-utils==8.0.1 # via # -r requirements/test.txt @@ -130,6 +132,7 @@ edx-drf-extensions==10.6.0 edx-opaque-keys==3.0.0 # via # -r requirements/test.txt + # edx-ccx-keys # edx-drf-extensions # edx-organizations edx-organizations==7.3.0 @@ -292,6 +295,10 @@ simpleeval==1.0.3 # via # -r requirements/test.txt # pycasbin +six==1.17.0 + # via + # -r requirements/test.txt + # edx-ccx-keys snowballstemmer==3.0.1 # via sphinx soupsieve==2.8 diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index 0bfdd3f4..d391764e 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -4,7 +4,7 @@ # # pip-compile --output-file=requirements/pip-tools.txt requirements/pip-tools.in # -build==1.4.0 +build==1.4.2 # via pip-tools click==8.3.1 # via pip-tools diff --git a/requirements/quality.txt b/requirements/quality.txt index ac92c0df..3c3af9d2 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -109,6 +109,8 @@ drf-yasg==1.21.11 # edx-api-doc-tools edx-api-doc-tools==2.1.2 # via -r requirements/test.txt +edx-ccx-keys==2.0.2 + # via -r requirements/test.txt edx-django-utils==8.0.1 # via # -r requirements/test.txt @@ -122,6 +124,7 @@ edx-lint==5.6.0 edx-opaque-keys==3.0.0 # via # -r requirements/test.txt + # edx-ccx-keys # edx-drf-extensions # edx-organizations edx-organizations==7.3.0 @@ -250,7 +253,10 @@ simpleeval==1.0.3 # -r requirements/test.txt # pycasbin six==1.17.0 - # via edx-lint + # via + # -r requirements/test.txt + # edx-ccx-keys + # edx-lint snowballstemmer==3.0.1 # via pydocstyle sqlparse==0.5.3 diff --git a/requirements/test.txt b/requirements/test.txt index 36a8ae2d..244228fb 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -94,6 +94,8 @@ drf-yasg==1.21.11 # edx-api-doc-tools edx-api-doc-tools==2.1.2 # via -r requirements/base.txt +edx-ccx-keys==2.0.2 + # via -r requirements/base.txt edx-django-utils==8.0.1 # via # -r requirements/base.txt @@ -105,6 +107,7 @@ edx-drf-extensions==10.6.0 edx-opaque-keys==3.0.0 # via # -r requirements/base.txt + # edx-ccx-keys # edx-drf-extensions # edx-organizations edx-organizations==7.3.0 @@ -196,6 +199,10 @@ simpleeval==1.0.3 # via # -r requirements/base.txt # pycasbin +six==1.17.0 + # via + # -r requirements/base.txt + # edx-ccx-keys sqlparse==0.5.3 # via # -r requirements/base.txt From 39d8a31da976cfe736a524bbdb8c0cc8ed6c1367 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Fri, 3 Apr 2026 11:14:31 -0400 Subject: [PATCH 2/6] style: Fix formatting --- openedx_authz/api/data.py | 4 ++-- openedx_authz/constants/roles.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openedx_authz/api/data.py b/openedx_authz/api/data.py index 9adb6179..a9b2aa83 100644 --- a/openedx_authz/api/data.py +++ b/openedx_authz/api/data.py @@ -838,8 +838,8 @@ class OrgCourseOverviewGlobData(OrgGlobData): NAMESPACE: ClassVar[str] = "course-v1" ID_SEPARATOR: ClassVar[str] = "+" - - + + class CCXCourseOverviewData(CourseOverviewData): """CCX course scope for authorization in the Open edX platform. diff --git a/openedx_authz/constants/roles.py b/openedx_authz/constants/roles.py index 17971ba4..99badb03 100644 --- a/openedx_authz/constants/roles.py +++ b/openedx_authz/constants/roles.py @@ -181,7 +181,7 @@ COURSE_BETA_TESTER = RoleData(external_key="course_beta_tester", permissions=COURSE_BETA_TESTER_PERMISSIONS) # This is a known LMS-only permission, but doesn't actually grant anything yet. -# +# # It is intended to be handled in the Willow time frame. CCX_COACH_PERMISSIONS = [] CCX_COACH = RoleData(external_key="ccx_coach", permissions=CCX_COACH_PERMISSIONS) From b245375fb5cf3810b73e8650eb146a4158f711d2 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Fri, 3 Apr 2026 11:16:54 -0400 Subject: [PATCH 3/6] refactor: Clarify migration command output Previously if there were any errors, success messages were eaten. Added message to state why no roles may have been migrated (already migrated, bad ids) --- openedx_authz/engine/utils.py | 3 ++- .../commands/authz_migrate_course_authoring.py | 17 +++++++++++++++-- .../commands/authz_rollback_course_authoring.py | 17 +++++++++++++++-- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py index c86dd244..c6d6f8ab 100644 --- a/openedx_authz/engine/utils.py +++ b/openedx_authz/engine/utils.py @@ -258,7 +258,8 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis if not is_user_added: logger.error( f"Failed to migrate permission for User: {permission.user.username} " - f"to Role: {role} in Scope: {permission.course_id}" + f"to Role: {role} in Scope: {permission.course_id} " + "user may already have this permission assigned" ) permissions_with_errors.append(permission) continue diff --git a/openedx_authz/management/commands/authz_migrate_course_authoring.py b/openedx_authz/management/commands/authz_migrate_course_authoring.py index 1968f5fa..421fa4dc 100644 --- a/openedx_authz/management/commands/authz_migrate_course_authoring.py +++ b/openedx_authz/management/commands/authz_migrate_course_authoring.py @@ -70,12 +70,25 @@ def handle(self, *args, **options): delete_after_migration=delete_after_migration, ) - if errors: + if errors and success: + self.stdout.write( + self.style.WARNING( + f"Migration completed with {len(errors)} errors and {len(success)} roles migrated." + ) + ) + elif errors: self.stdout.write(self.style.ERROR(f"Migration completed with {len(errors)} errors.")) - else: + elif success: self.stdout.write( self.style.SUCCESS(f"Migration completed successfully with {len(success)} roles migrated.") ) + else: + self.stdout.write( + self.style.ERROR( + "No legacy roles found for the given scope, course could already be migrated, " + "or there coule be a an error in the course_id_list / org_id." + ) + ) if delete_after_migration: self.stdout.write(self.style.SUCCESS(f"{len(success)} Legacy roles deleted successfully.")) diff --git a/openedx_authz/management/commands/authz_rollback_course_authoring.py b/openedx_authz/management/commands/authz_rollback_course_authoring.py index 7d323998..d95ca11d 100644 --- a/openedx_authz/management/commands/authz_rollback_course_authoring.py +++ b/openedx_authz/management/commands/authz_rollback_course_authoring.py @@ -74,12 +74,25 @@ def handle(self, *args, **options): delete_after_migration=delete_after_migration, # control deletion here ) - if errors: + if errors and success: + self.stdout.write( + self.style.WARNING( + f"Rollback completed with {len(errors)} errors and {len(success)} roles migrated." + ) + ) + elif errors: self.stdout.write(self.style.ERROR(f"Rollback completed with {len(errors)} errors.")) - else: + elif success: self.stdout.write( self.style.SUCCESS(f"Rollback completed successfully with {len(success)} roles rolled back.") ) + else: + self.stdout.write( + self.style.ERROR( + "No roles found for the given scope, course could already be rolled back, " + "or there coule be a an error in the course_id_list / org_id." + ) + ) if delete_after_migration: self.stdout.write( From ab1329f07def8a43e1447d03a5c3e20130a55ac8 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Fri, 3 Apr 2026 11:31:42 -0400 Subject: [PATCH 4/6] style: Use a generator for checking valid course key input --- openedx_authz/engine/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py index c6d6f8ab..e844dcf8 100644 --- a/openedx_authz/engine/utils.py +++ b/openedx_authz/engine/utils.py @@ -178,7 +178,7 @@ def _validate_migration_input(course_id_list, org_id): "At least one of course_id_list or org_id must be provided to limit the scope of the migration." ) - if course_id_list and any([course_key for course_key in course_id_list if not course_key.startswith("course-v1:")]): + if course_id_list and any(not course_key.startswith("course-v1:") for course_key in course_id_list): raise ValueError( "Only full course keys (e.g., 'course-v1:org+course+run') are supported in the course_id_list." " Other course types such as CCX are not supported." From f5db108a8937691be12c752500ed6027356c37a3 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Fri, 3 Apr 2026 12:50:56 -0400 Subject: [PATCH 5/6] test: Pick up coverge changes --- openedx_authz/tests/test_migrations.py | 91 ++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/openedx_authz/tests/test_migrations.py b/openedx_authz/tests/test_migrations.py index 07a98d51..17450ebb 100644 --- a/openedx_authz/tests/test_migrations.py +++ b/openedx_authz/tests/test_migrations.py @@ -942,6 +942,51 @@ def test_authz_migrate_course_authoring_command(self, mock_migrate): self.assertEqual(kwargs["delete_after_migration"], True) + @patch("openedx_authz.management.commands.authz_migrate_course_authoring.CourseAccessRole", CourseAccessRole) + @patch("openedx_authz.management.commands.authz_migrate_course_authoring.migrate_legacy_course_roles_to_authz") + def test_authz_migrate_course_authoring_command_mixed_success(self, mock_migrate): + """ + Verify that the authz_migrate_course_authoring command outputs without errors + for mixed success operations. + """ + + mock_migrate.return_value = ( + ["course-v1:fail"], + [self.course_id], + ) # Return one success and one failure + + call_command("authz_migrate_course_authoring", "--course-id-list", self.course_id) + mock_migrate.assert_called_once() + + # Return only one success + mock_migrate.reset_mock() + mock_migrate.return_value = ( + [], + [self.course_id], + ) + + call_command("authz_migrate_course_authoring", "--course-id-list", self.course_id) + mock_migrate.assert_called_once() + + # Return only one failure + mock_migrate.reset_mock() + mock_migrate.return_value = ( + [self.course_id], + [], + ) + + call_command("authz_migrate_course_authoring", "--course-id-list", self.course_id) + mock_migrate.assert_called_once() + + # Return only no successes or failures + mock_migrate.reset_mock() + mock_migrate.return_value = ( + [], + [], + ) + call_command("authz_migrate_course_authoring", "--course-id-list", self.course_id) + mock_migrate.assert_called_once() + @patch("openedx_authz.management.commands.authz_rollback_course_authoring.CourseAccessRole", CourseAccessRole) @patch("openedx_authz.management.commands.authz_rollback_course_authoring.migrate_authz_to_legacy_course_roles") def test_authz_rollback_course_authoring_command(self, mock_rollback): @@ -972,6 +1017,52 @@ def test_authz_rollback_course_authoring_command(self, mock_rollback): self.assertEqual(call_kwargs["delete_after_migration"], True) + @patch("openedx_authz.management.commands.authz_rollback_course_authoring.CourseAccessRole", CourseAccessRole) + @patch("openedx_authz.management.commands.authz_rollback_course_authoring.migrate_authz_to_legacy_course_roles") + def test_authz_rollback_course_authoring_command_mixed_success(self, mock_rollback): + """ + Verify that the authz_rollback_course_authoring command does not error in + mixed success operations. + """ + + # Return one success and one failure + mock_rollback.return_value = ( + ["course-v1:fail"], + [self.course_id], + ) + call_command("authz_rollback_course_authoring", "--course-id-list", self.course_id) + mock_rollback.assert_called_once() + + # Return only one success + mock_rollback.reset_mock() + mock_rollback.return_value = ( + [], + [self.course_id], + ) + + call_command("authz_rollback_course_authoring", "--course-id-list", self.course_id) + mock_rollback.assert_called_once() + + # Return only one failure + mock_rollback.reset_mock() + mock_rollback.return_value = ( + [self.course_id], + [], + ) + + call_command("authz_rollback_course_authoring", "--course-id-list", self.course_id) + mock_rollback.assert_called_once() + + # Return only no successes or failures + mock_rollback.reset_mock() + mock_rollback.return_value = ( + [], + [], + ) + + call_command("authz_rollback_course_authoring", "--course-id-list", self.course_id) + mock_rollback.assert_called_once() + @patch("openedx_authz.management.commands.authz_migrate_course_authoring.CourseAccessRole", CourseAccessRole) @patch("openedx_authz.management.commands.authz_migrate_course_authoring.migrate_legacy_course_roles_to_authz") def test_authz_migrate_course_authoring_command_delete_confirmation_no(self, mock_migrate): From 4b7f6ff246da2f66c32dfe0addf60163533fb513 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Fri, 3 Apr 2026 13:29:06 -0400 Subject: [PATCH 6/6] build: Remove Django 4.2 tests, we no longer support it --- .github/workflows/ci.yml | 4 ++-- tox.ini | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33974a9a..d98af8d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: matrix: os: [ubuntu-latest] python-version: ["3.12"] - toxenv: [quality, docs, pii_check, django42, django52] + toxenv: [quality, docs, pii_check, django52] steps: - uses: actions/checkout@v6 - name: setup python @@ -35,7 +35,7 @@ jobs: run: tox - name: Run coverage - if: matrix.python-version == '3.12' && matrix.toxenv == 'django42' + if: matrix.python-version == '3.12' && matrix.toxenv == 'django52' uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/tox.ini b/tox.ini index 47300b95..5ea9233a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py312-django{42,52} +envlist = py312-django{52} [doc8] ; D001 = Line too long @@ -37,7 +37,6 @@ norecursedirs = .* docs requirements site-packages [testenv] deps = - django42: Django>=4.0,<5.0 django52: Django>=5.0,<5.3 -r{toxinidir}/requirements/test.txt commands =