diff --git a/openedx_authz/admin.py b/openedx_authz/admin.py index e2285158..c77ebdff 100644 --- a/openedx_authz/admin.py +++ b/openedx_authz/admin.py @@ -4,7 +4,7 @@ from django import forms from django.contrib import admin -from openedx_authz.models import ExtendedCasbinRule +from openedx_authz.models import AuthzCourseAuthoringMigrationRun, ExtendedCasbinRule class CasbinRuleForm(forms.ModelForm): @@ -48,3 +48,12 @@ class CasbinRuleAdmin(admin.ModelAdmin): # TODO: In a future, possibly we should only show an inline for the rules that # have an extended rule, and show the subject and scope information in detail. inlines = [ExtendedCasbinRuleInline] + + +@admin.register(AuthzCourseAuthoringMigrationRun) +class AuthzCourseAuthoringMigrationRunAdmin(admin.ModelAdmin): + """Admin for AuthzCourseAuthoringMigrationRun to display additional metadata.""" + + list_display = ("id", "scope_type", "scope_key", "migration_type", "status", "created_at", "updated_at") + search_fields = ("scope_type", "scope_key", "migration_type", "status") + list_filter = ("scope_type", "migration_type", "status") diff --git a/openedx_authz/api/data.py b/openedx_authz/api/data.py index e6e18f41..8caa450f 100644 --- a/openedx_authz/api/data.py +++ b/openedx_authz/api/data.py @@ -695,6 +695,24 @@ def get_admin_view_permission(cls) -> PermissionData: """ raise NotImplementedError("Subclasses must implement get_admin_view_permission method.") + @classmethod + def build_external_key(cls, org: str) -> str: + """Build the external key for all resources within the given organization. + + Args: + org (str): The organization identifier (e.g., ``DemoX``). + + Returns: + str: The external key for the org-level glob (e.g., ``course-v1:DemoX+*``). + + Examples: + >>> OrgCourseOverviewGlobData.build_external_key('DemoX') + 'course-v1:DemoX+*' + >>> OrgContentLibraryGlobData.build_external_key('DemoX') + 'lib:DemoX:*' + """ + return f"{cls.NAMESPACE}{EXTERNAL_KEY_SEPARATOR}{org}{cls.ID_SEPARATOR}{GLOBAL_SCOPE_WILDCARD}" + @classmethod def get_org(cls, external_key: str) -> str | None: """Extract the organization identifier from the glob pattern. diff --git a/openedx_authz/api/roles.py b/openedx_authz/api/roles.py index 1215b79b..f56ee8e6 100644 --- a/openedx_authz/api/roles.py +++ b/openedx_authz/api/roles.py @@ -44,6 +44,7 @@ "get_subject_role_assignments", "get_subject_role_assignments_for_role_in_scope", "get_subject_role_assignments_in_scope", + "get_all_role_assignments_per_scope_type", "unassign_role_from_subject_in_scope", "unassign_subject_from_all_roles", ] @@ -553,3 +554,23 @@ def unassign_subject_from_all_roles(subject: SubjectData) -> bool: """ enforcer = AuthzEnforcer.get_enforcer() return enforcer.remove_filtered_grouping_policy(GroupingPolicyIndex.SUBJECT.value, subject.namespaced_key) + + +def get_all_role_assignments_per_scope_type(scope_types: list[type[ScopeData]]) -> list[RoleAssignmentData]: + """Get all role assignments matching any of the given scope types. + + Loads all grouping policies from the enforcer and filters in Python. Casbin policies + store full scope keys (e.g., 'course-v1^course-v1:Org+Course+Run'), so there is no + way to query by scope type directly so the filtering must happen here. + + Args: + scope_types: A list of ScopeData subclasses (not instances). Assignments matching + any of the given types are returned. + + Returns: + list[RoleAssignmentData]: All assignments whose scope is an instance of any of the given scope types. + """ + return [ + role_assignment for role_assignment in get_role_assignments() + if isinstance(role_assignment.scope, tuple(scope_types)) + ] diff --git a/openedx_authz/engine/utils.py b/openedx_authz/engine/utils.py index e844dcf8..c5f4a3c3 100644 --- a/openedx_authz/engine/utils.py +++ b/openedx_authz/engine/utils.py @@ -6,31 +6,76 @@ import logging from collections import defaultdict +from collections.abc import Callable +from typing import Any from casbin import Enforcer +from django.contrib.auth.models import AbstractUser +from django.core.cache import cache -from openedx_authz.api.data import CourseOverviewData +from openedx_authz.api.data import CourseOverviewData, OrgCourseOverviewGlobData, RoleAssignmentData +from openedx_authz.api.roles import get_all_role_assignments_per_scope_type from openedx_authz.api.users import ( assign_role_to_user_in_scope, batch_assign_role_to_users_in_scope, batch_unassign_role_from_users, - get_user_role_assignments, -) -from openedx_authz.constants.roles import ( - LEGACY_COURSE_ROLE_EQUIVALENCES, - LIBRARY_ADMIN, - LIBRARY_AUTHOR, - LIBRARY_USER, ) +from openedx_authz.constants.roles import LEGACY_COURSE_ROLE_EQUIVALENCES, LIBRARY_ADMIN, LIBRARY_AUTHOR, LIBRARY_USER +from openedx_authz.models.migrations import AuthzCourseAuthoringMigrationRun, MigrationType, ScopeType logger = logging.getLogger(__name__) -GROUPING_POLICY_PTYPES = ["g", "g2", "g3", "g4", "g5", "g6"] +GROUPING_POLICY_PTYPES = ["g", "g2", "g3", "g4", "g5", "g6"] # Map new roles back to legacy roles for rollback purposes COURSE_ROLE_EQUIVALENCES = {v: k for k, v in LEGACY_COURSE_ROLE_EQUIVALENCES.items()} +MIGRATION_LOCK_TIMEOUT = 60 * 60 # 1 hour + + +def _get_lock_key(scope_type: ScopeType, scope_key: str) -> str: + """Generate a cache key for migration locking. + + Args: + scope_type (ScopeType): Type of scope ('course' or 'org') + scope_key (str): Identifier for the scope + + Returns: + str: Cache key for the migration run lock + """ + return f"authz_migration_lock:{scope_type}:{scope_key}" + + +def _acquire_lock(scope_type: ScopeType, scope_key: str, migration_run_id: int) -> bool: + """Acquire a lock for migration to prevent concurrent migrations. + + cache.add() returns True only if the key did not exist (lock acquired). + If it returns False, another migration run is already in progress. + + Args: + scope_type (ScopeType): Type of scope ('course' or 'org') + scope_key (str): Identifier for the scope + migration_run_id (int): Unique migration run identifier + timeout (int): Lock timeout in seconds + + Returns: + bool: True if migration run lock was acquired, False otherwise + """ + lock_key = _get_lock_key(scope_type, scope_key) + return cache.add(lock_key, migration_run_id, MIGRATION_LOCK_TIMEOUT) + + +def _release_lock(scope_type: ScopeType, scope_key: str) -> None: + """Release a migration lock. + + Args: + scope_type (ScopeType): Type of scope ('course' or 'org') + scope_key (str): Identifier for the scope + """ + lock_key = _get_lock_key(scope_type, scope_key) + cache.delete(lock_key) + def migrate_policy_between_enforcers( source_enforcer: Enforcer, @@ -185,81 +230,128 @@ def _validate_migration_input(course_id_list, org_id): ) -def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_list, org_id, delete_after_migration): +def _run_scoped_migration( + migration_type: MigrationType, + course_id_list: list[str] | None, + org_id: str | None, + delete_after_migration: bool, + process_scope_fn: Callable[[ScopeType, str], tuple[list, list]], +) -> tuple[list, list]: + """Orchestrate a migration over a set of scopes with per-scope locking and tracking. + + For each scope, creates a ``AuthzCourseAuthoringMigrationRun``, acquires a distributed lock, + and delegates the work to ``process_scope_fn``. Scopes whose lock cannot be acquired are + marked SKIPPED. + + Args: + migration_type (MigrationType): Direction of the migration (forward or rollback). + course_id_list (list[str] | None): List of course IDs to migrate individually. + Mutually exclusive with ``org_id``. + org_id (str | None): Organization ID to migrate as a single org-scoped run. + Mutually exclusive with ``course_id_list``. + delete_after_migration (bool): Whether to delete successfully migrated entries after migration. + process_scope_fn (Callable[[ScopeType, str], tuple[list, list]]): Callable that + receives ``(scope_type, scope_key)`` and returns a ``(errors, successes)`` + tuple of ``RoleAssignmentData`` lists. + + Returns: + A tuple of ``(errors, successes)`` aggregated across all processed scopes, where + each element is a list of ``RoleAssignmentData`` instances. """ - Migrate legacy course role data to the new Casbin-based authorization model. - This function reads legacy permissions from the CourseAccessRole model - and assigns equivalent roles in the new authorization system. + scopes_to_process = ( + [(ScopeType.COURSE, course_id) for course_id in course_id_list] if course_id_list else [(ScopeType.ORG, org_id)] + ) - The old Course permissions are stored in the CourseAccessRole model, it consists of the following columns: + all_errors = [] + all_successes = [] - - user: FK to User - - org: optional Organization string - - course_id: optional CourseKeyField of Course - - role: 'instructor' | 'staff' | 'limited_staff' | 'data_researcher' + for scope_type, scope_key in scopes_to_process: + metadata = { + "course_id": scope_key if scope_type == ScopeType.COURSE else None, + "org_id": org_id if scope_type == ScopeType.ORG else None, + "delete_after": delete_after_migration, + } + migration_run = AuthzCourseAuthoringMigrationRun.create_pending(migration_type, scope_type, scope_key, metadata) - In the new Authz model, this would roughly translate to: + if not _acquire_lock(scope_type, scope_key, migration_run.id): + logger.warning(f"Migration already in progress for {scope_type}:{scope_key}.") + migration_run.mark_skipped(reason="locked") + continue - - course_id: scope - - user: subject - - role: role + migration_run.mark_running() - param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function - is intended to run within a Django migration context, where direct model imports can cause issues. - param course_id_list: Optional list of course IDs to filter the migration. - param org_id: Optional organization ID to filter the migration. - param delete_after_migration: Whether to delete successfully migrated legacy permissions after migration. - """ - _validate_migration_input(course_id_list, org_id) + errors, successes = process_scope_fn(scope_type, scope_key) + all_errors.extend(errors) + all_successes.extend(successes) - course_access_role_filter = { - "course_id__startswith": "course-v1:", - } + migration_run.mark_completed(metadata_updates={"success_count": len(successes), "error_count": len(errors)}) + _release_lock(scope_type, scope_key) - if org_id: - course_access_role_filter["org"] = org_id + return all_errors, all_successes - if course_id_list and not org_id: - # Only filter by course_id if org_id is not provided, - # otherwise we will filter by org_id which is more efficient - course_access_role_filter["course_id__in"] = course_id_list - legacy_permissions = ( - course_access_role_model.objects.filter(**course_access_role_filter).select_related("user").all() - ) +def _process_forward_permissions( + legacy_permissions: list, + course_access_role_model: Any, + delete_after_migration: bool, +) -> tuple[list, list]: + """Process a batch of legacy course role permissions and assign them in the Casbin-based model. - # List to keep track of any permissions that could not be migrated + For each permission, resolves the equivalent Casbin role, determines the scope + (course or org), and calls ``assign_role_to_user_in_scope``. Permissions with + unknown roles or missing scope data are collected as errors without raising. + If ``delete_after_migration`` is ``True``, successfully migrated records are + deleted from the legacy ``CourseAccessRole`` table. + + Args: + legacy_permissions (list): ``CourseAccessRole`` instances to migrate. + course_access_role_model (Any): The ``CourseAccessRole`` Django model class, + used to delete records after successful migration. + delete_after_migration (bool): If ``True``, deletes each successfully migrated + record from the legacy table. + + Returns: + tuple[list, list]: ``(errors, successes)`` — two lists of ``CourseAccessRole`` + instances. ``errors`` contains records that could not be migrated (unknown + role, missing scope, or failed assignment); ``successes`` contains those + that were migrated and, if requested, deleted. + """ permissions_with_errors = [] permissions_with_no_errors = [] for permission in legacy_permissions: - # Migrate the permission to the new model - role = LEGACY_COURSE_ROLE_EQUIVALENCES.get(permission.role) + if role is None: - # This should not happen as there are no more access_levels defined - # in CourseAccessRole, log and skip logger.error(f"Unknown access level: {permission.role} for User: {permission.user}") permissions_with_errors.append(permission) continue - # Permission applied to individual user + if permission.course_id: + scope_external_key = str(permission.course_id) + elif permission.org: + scope_external_key = OrgCourseOverviewGlobData.build_external_key(permission.org) + else: + logger.error( + f"Permission for User: {permission.user.username} has neither course_id nor org defined, skipping." + ) + permissions_with_errors.append(permission) + continue + logger.info( - f"Migrating permission for User: {permission.user.username} " - f"to Role: {role} in Scope: {permission.course_id}" + f"Migrating permission for User: {permission.user.username} to Role: {role} in Scope: {scope_external_key}" ) is_user_added = assign_role_to_user_in_scope( user_external_key=permission.user.username, role_external_key=role, - scope_external_key=str(permission.course_id), + scope_external_key=scope_external_key, ) 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} " - "user may already have this permission assigned" + f"to Role: {role} in Scope: {scope_external_key}" ) permissions_with_errors.append(permission) continue @@ -267,7 +359,6 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis permissions_with_no_errors.append(permission) if delete_after_migration: - # Only delete permissions that were successfully migrated to avoid data loss. course_access_role_model.objects.filter(id__in=[p.id for p in permissions_with_no_errors]).delete() logger.info(f"Deleted {len(permissions_with_no_errors)} legacy permissions after successful migration.") logger.info(f"Retained {len(permissions_with_errors)} legacy permissions that had errors during migration.") @@ -275,95 +366,161 @@ def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_lis return permissions_with_errors, permissions_with_no_errors -def migrate_authz_to_legacy_course_roles( - course_access_role_model, user_subject_model, course_id_list, org_id, delete_after_migration -): +def migrate_legacy_course_roles_to_authz(course_access_role_model, course_id_list, org_id, delete_after_migration): """ - Migrate permissions from the new Casbin-based authorization model back to the legacy CourseAccessRole model. - This function reads permissions from the Casbin enforcer and creates equivalent entries in the - CourseAccessRole model. + Migrate legacy course role data to the new Casbin-based authorization model. + This function reads legacy permissions from the CourseAccessRole model + and assigns equivalent roles in the new authorization system. - This is essentially the reverse of migrate_legacy_course_roles_to_authz and is intended - for rollback purposes in case of migration issues. + The old Course permissions are stored in the CourseAccessRole model, it consists of the following columns: + + - user: FK to User + - org: optional Organization string + - course_id: optional CourseKeyField of Course + - role: 'instructor' | 'staff' | 'limited_staff' | 'data_researcher' + + In the new Authz model, this would roughly translate to: + + - course_id: scope + - user: subject + - role: role + + The scope assigned per row depends on which fields are set: + - course_id set: course-level scope (e.g. "course-v1:OpenedX+CS101+2024"). + - course_id blank, org set: org-level glob scope (e.g. "course-v1:OpenedX+*"). + - both set: course_id takes precedence as the more specific scope. + + When course_id_list is provided, one MigrationRun record is created per course ID so that each + course is tracked, locked, and completed independently. When only org_id is provided, a single + org-scoped MigrationRun is created instead. param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function is intended to run within a Django migration context, where direct model imports can cause issues. - param user_subject_model: It should be the UserSubject model. This is passed in because the function - is intended to run within a Django migration context, where direct model imports can cause issues. param course_id_list: Optional list of course IDs to filter the migration. param org_id: Optional organization ID to filter the migration. - param delete_after_migration: Whether to unassign successfully migrated permissions - from the new model after migration. + param delete_after_migration: Whether to delete successfully migrated legacy permissions after 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. - course_subject_filter = { - "casbin_rules__scope__coursescope__course_overview__isnull": False, - } + course_access_role_filter = {} if org_id: - course_subject_filter["casbin_rules__scope__coursescope__course_overview__org"] = org_id - - if course_id_list and not org_id: + course_access_role_filter["org"] = org_id + elif course_id_list: # Only filter by course_id if org_id is not provided, # otherwise we will filter by org_id which is more efficient - course_subject_filter["casbin_rules__scope__coursescope__course_overview__id__in"] = course_id_list + course_access_role_filter["course_id__in"] = course_id_list - course_subjects = user_subject_model.objects.filter(**course_subject_filter).select_related("user").distinct() + legacy_permissions = list( + course_access_role_model.objects.filter(**course_access_role_filter).select_related("user").all() + ) + + def process_scope(scope_type: ScopeType, scope_key: str) -> tuple[list, list]: + """ + Select the appropriate permission slice for the given scope + and delegate to ``_process_forward_permissions``. + + For course scopes, only permissions whose ``course_id`` matches ``scope_key`` are processed. + For org scopes, the full ``legacy_permissions`` list is used. + """ + if scope_type == ScopeType.COURSE: + scope_permissions = [perm for perm in legacy_permissions if str(perm.course_id) == scope_key] + else: + scope_permissions = legacy_permissions + return _process_forward_permissions( + legacy_permissions=scope_permissions, + course_access_role_model=course_access_role_model, + delete_after_migration=delete_after_migration, + ) + + return _run_scoped_migration( + migration_type=MigrationType.FORWARD, + course_id_list=course_id_list, + org_id=org_id, + delete_after_migration=delete_after_migration, + process_scope_fn=process_scope, + ) + + +def _process_rollback_assignments( + role_assignments: list[RoleAssignmentData], + users_by_username: dict[str, AbstractUser], + course_access_role_model: Any, + delete_after_migration: bool, +) -> tuple[list, list]: + """Recreate legacy CourseAccessRole entries from a list of openedx-authz role assignments. + + For each assignment, resolves the corresponding user and role, then calls ``get_or_create`` + on the legacy model to avoid duplicates. Course-scoped assignments populate both ``org`` + and ``course_id``; org-scoped assignments populate only ``org``. Any assignment with an + unsupported scope type is logged and collected as an error. + If ``delete_after_migration`` is True, successfully migrated assignments are unassigned + from the new AuthZ model after all entries have been recreated. + + Args: + role_assignments: List of openedx-authz role assignments to roll back. + users_by_username: Mapping of username to User instance, used to resolve the + user FK for each CourseAccessRole entry. + course_access_role_model: The ``CourseAccessRole`` Django model. Passed explicitly + to avoid import issues in Django migration contexts. + delete_after_migration: If True, unassigns each successfully migrated assignment + from the new AuthZ model after recreating its legacy entry. + + Returns: + A tuple of ``(errors, successes)``, where each element is a list of + ``RoleAssignmentData`` instances. ``errors`` contains assignments that could not + be migrated; ``successes`` contains those that were migrated successfully. + """ roles_with_errors = [] roles_with_no_errors = [] unassignments = defaultdict(list) - for course_subject in course_subjects: - user = course_subject.user - user_external_key = user.username + for role_assignment in role_assignments: + try: + user_external_key = role_assignment.subject.external_key + role_external_key = role_assignment.roles[0].external_key + scope_external_key = role_assignment.scope.external_key + + course_access_role_kwargs = { + "user": users_by_username[user_external_key], + "role": COURSE_ROLE_EQUIVALENCES[role_external_key], + } + + if isinstance(role_assignment.scope, CourseOverviewData): + course_access_role_kwargs["org"] = role_assignment.scope.org + course_access_role_kwargs["course_id"] = scope_external_key + elif isinstance(role_assignment.scope, OrgCourseOverviewGlobData): + course_access_role_kwargs["org"] = role_assignment.scope.org + else: + # This would only happen for course roles assigned instance-wide + # which is not yet supported + logger.error( + f"Unexpected scope type: {type(role_assignment.scope)} for RoleAssignment with " + f"scope: {scope_external_key}, user: {user_external_key} and role: {role_external_key}, skipping." + ) + roles_with_errors.append(role_assignment) + continue - # 2. Get all role assignments for the user - role_assignments = get_user_role_assignments(user_external_key=user_external_key) + course_access_role_model.objects.get_or_create(**course_access_role_kwargs) + roles_with_no_errors.append(role_assignment) - for assignment in role_assignments: - if not isinstance(assignment.scope, CourseOverviewData): - logger.error(f"Skipping role assignment for User: {user_external_key} due to missing course scope.") - continue + logger.info( + f"Successfully rolled back RoleAssignment for User: {user_external_key} " + f"in Role: {role_external_key} and Scope: {scope_external_key} " + f"to legacy CourseAccessRole entry." + ) + + if delete_after_migration: + unassignments[(role_external_key, scope_external_key)].append(user_external_key) + + except Exception as e: # pylint: disable=broad-exception-caught + logger.error( + f"Error rolling back RoleAssignment for User: {role_assignment.subject.external_key} " + f"in Role: {role_assignment.roles[0].external_key} and Scope: {role_assignment.scope.external_key}: {e}" + ) + roles_with_errors.append(role_assignment) - scope = assignment.scope.external_key - - course_overview = assignment.scope.get_object() - - for role in assignment.roles: - legacy_role = COURSE_ROLE_EQUIVALENCES.get(role.external_key) - if legacy_role is None: - logger.error(f"Unknown role: {role} for User: {user_external_key}") - roles_with_errors.append((user_external_key, role.external_key, scope)) - continue - - try: - # Create legacy CourseAccessRole entry - course_access_role_model.objects.get_or_create( - user=user, - org=course_overview.org, - course_id=scope, - role=legacy_role, - ) - roles_with_no_errors.append((user_external_key, role.external_key, scope)) - except Exception as e: # pylint: disable=broad-exception-caught - logger.error( - f"Error creating CourseAccessRole for User: " - f"{user_external_key}, Role: {legacy_role}, Course: {scope}: {e}" - ) - roles_with_errors.append((user_external_key, role.external_key, scope)) - continue - - # If we successfully created the legacy role, we can add this role assignment - # to the unassignment list if delete_after_migration is True - if delete_after_migration: - unassignments[(role.external_key, scope)].append(user_external_key) - - # Once the loop is done, we can log summary of unassignments - # and perform batch unassignment if delete_after_migration is True if delete_after_migration: total_unassignments = sum(len(users) for users in unassignments.values()) logger.info(f"Total of {total_unassignments} role assignments unassigned after successful rollback migration.") @@ -379,3 +536,92 @@ def migrate_authz_to_legacy_course_roles( ) return roles_with_errors, roles_with_no_errors + + +def migrate_authz_to_legacy_course_roles( + course_access_role_model, user_subject_model, course_id_list, org_id, delete_after_migration +): + """ + Migrate permissions from the new Casbin-based authorization model back to the legacy CourseAccessRole model. + This function reads permissions from the Casbin enforcer and creates equivalent entries in the + CourseAccessRole model. + + This is essentially the reverse of migrate_legacy_course_roles_to_authz and is intended + for rollback purposes in case of migration issues. + + To build each CourseAccessRole entry, the function needs: + - A user: resolved from role assignments in scopes linked to courses. + - A scope: a CourseOverviewData or OrgCourseOverviewGlobData instance, optionally filtered by course_id or org_id. + - A role: a role external key that maps to a legacy role in COURSE_ROLE_EQUIVALENCES. + + When course_id_list is provided, one MigrationRun record is created per course ID so that each + course is tracked, locked, and completed independently. When only org_id is provided, a single + org-scoped MigrationRun is created instead. + + param course_access_role_model: It should be the CourseAccessRole model. This is passed in because the function + is intended to run within a Django migration context, where direct model imports can cause issues. + param user_subject_model: It should be the UserSubject model. This is passed in because the function + is intended to run within a Django migration context, where direct model imports can cause issues. + param course_id_list: Optional list of course IDs to filter the migration. + param org_id: Optional organization ID to filter the migration. + param delete_after_migration: Whether to unassign successfully migrated permissions + from the new model after migration. + """ + _validate_migration_input(course_id_list, org_id) + + role_assignments = get_all_role_assignments_per_scope_type( + scope_types=[CourseOverviewData, OrgCourseOverviewGlobData] + ) + + user_external_keys = set() + assignments_by_course = defaultdict(list) + filtered_assignments = [] + + for role_assignment in role_assignments: + # If org_id is provided, skip assignments that don't belong to the target org + # including org-level glob and course-level assignments + if org_id and role_assignment.scope.org != org_id: + continue + # Otherwise, keep the assignment + filtered_assignments.append(role_assignment) + + # collect usernames for the DB query below + user_external_keys.add(role_assignment.subject.external_key) + + # Only course-level assignments are grouped by course_id + if isinstance(role_assignment.scope, CourseOverviewData): + assignments_by_course[role_assignment.scope.course_id].append(role_assignment) + + users_by_username = { + subject.user.username: subject.user + for subject in user_subject_model.objects.filter( + user__username__in=user_external_keys, + ).select_related("user") + } + + def process_scope(scope_type: ScopeType, scope_key: str) -> tuple[list, list]: + """ + Select the appropriate assignment slice for the given scope + and delegate to ``_process_rollback_assignments``. + + For course scopes, only assignments belonging to that specific course are processed. + For org scopes, the full ``filtered_assignments`` list is used. + """ + if scope_type == ScopeType.COURSE: + scope_assignments = assignments_by_course[scope_key] + else: + scope_assignments = filtered_assignments + return _process_rollback_assignments( + role_assignments=scope_assignments, + users_by_username=users_by_username, + course_access_role_model=course_access_role_model, + delete_after_migration=delete_after_migration, + ) + + return _run_scoped_migration( + migration_type=MigrationType.ROLLBACK, + course_id_list=course_id_list, + org_id=org_id, + delete_after_migration=delete_after_migration, + process_scope_fn=process_scope, + ) diff --git a/openedx_authz/handlers.py b/openedx_authz/handlers.py index 7701123e..1bfd9097 100644 --- a/openedx_authz/handlers.py +++ b/openedx_authz/handlers.py @@ -7,19 +7,32 @@ import logging from casbin_adapter.models import CasbinRule -from django.db.models.signals import post_delete +from django.conf import settings +from django.db.models.signals import post_delete, pre_save from django.dispatch import receiver from openedx_authz.api.users import unassign_all_roles_from_user from openedx_authz.models.core import ExtendedCasbinRule +from openedx_authz.models.migrations import MigrationType, ScopeType +from openedx_authz.tasks import migrate_course_authoring_async try: from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL except ImportError: USER_RETIRE_LMS_CRITICAL = None +try: + from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel, WaffleFlagOrgOverrideModel +except ImportError: + WaffleFlagCourseOverrideModel = None + WaffleFlagOrgOverrideModel = None + + logger = logging.getLogger(__name__) +# Flag name to monitor for automatic migration +AUTHZ_COURSE_AUTHORING_FLAG = "authz.enable_course_authoring" + @receiver(post_delete, sender=ExtendedCasbinRule) 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 # Only register the handler if the signal is available (i.e., running in Open edX) if USER_RETIRE_LMS_CRITICAL is not None: USER_RETIRE_LMS_CRITICAL.connect(unassign_roles_on_user_retirement) + + +def trigger_course_authoring_migration( + instance: WaffleFlagCourseOverrideModel | WaffleFlagOrgOverrideModel, + scope_type: ScopeType, + scope_key: str, +) -> None: + """Trigger an asynchronous migration run. + + Args: + instance: The waffle flag instance that triggered the migration + scope_type (ScopeType): Type of scope being migrated: course or organization + scope_key (str): Course ID or organization name + """ + if instance.waffle_flag != AUTHZ_COURSE_AUTHORING_FLAG: + return + + last_flag_obj = None + if isinstance(instance, WaffleFlagCourseOverrideModel): + last_flag_obj = ( + WaffleFlagCourseOverrideModel.objects.filter(course_id=instance.course_id).order_by("-id").first() + ) + elif isinstance(instance, WaffleFlagOrgOverrideModel): + last_flag_obj = WaffleFlagOrgOverrideModel.objects.filter(org=instance.org).order_by("-id").first() + + if last_flag_obj and last_flag_obj.enabled == instance.enabled: + logger.info("No change in waffle flag, skipping course migration") + return + + if not instance.enabled: + migration_type = MigrationType.ROLLBACK + else: + migration_type = MigrationType.FORWARD + + course_id_list = None + org_id = None + + if scope_type == ScopeType.COURSE: + course_id_list = [scope_key] + elif scope_type == ScopeType.ORG: + org_id = scope_key + + logger.info(f"Triggering {migration_type} migration for {scope_type}:{scope_key} due to waffle flag change") + + migrate_course_authoring_async( + migration_type=migration_type, + scope_type=scope_type, + scope_key=scope_key, + course_id_list=course_id_list, + org_id=org_id, + delete_after=True, + ) + + +@receiver(pre_save, sender=WaffleFlagCourseOverrideModel) +def handle_course_waffle_flag_change(sender, instance, **kwargs) -> None: # pylint: disable=unused-argument + """Handle changes to course-level waffle flags. + + When the authz.enable_course_authoring flag is changed for a course, + trigger the appropriate migration run. Only trigger if automatic migration + is enabled in the settings. + + Args: + sender: The model class (WaffleFlagCourseOverrideModel) + instance: The flag override instance being saved + **kwargs: Additional keyword arguments from the signal + """ + if not settings.ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION: + logger.info("Automatic migration is disabled, skipping course migration") + return + + trigger_course_authoring_migration( + instance=instance, + scope_type=ScopeType.COURSE, + scope_key=str(instance.course_id), + ) + + +@receiver(pre_save, sender=WaffleFlagOrgOverrideModel) +def handle_org_waffle_flag_change(sender, instance, **kwargs) -> None: # pylint: disable=unused-argument + """Handle changes to organization-level waffle flags. + + When the authz.enable_course_authoring flag is changed for an organization, + trigger the appropriate migration run. Only trigger if automatic migration + is enabled in the settings. + + Args: + sender: The model class (WaffleFlagOrgOverrideModel) + instance: The flag override instance being saved + **kwargs: Additional keyword arguments from the signal + """ + if not settings.ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION: + logger.info("Automatic migration is disabled, skipping organization migration") + return + + trigger_course_authoring_migration( + instance=instance, + scope_type=ScopeType.ORG, + scope_key=str(instance.org), + ) diff --git a/openedx_authz/migrations/0008_authzcourseauthoringmigrationrun.py b/openedx_authz/migrations/0008_authzcourseauthoringmigrationrun.py new file mode 100644 index 00000000..90a4a1b5 --- /dev/null +++ b/openedx_authz/migrations/0008_authzcourseauthoringmigrationrun.py @@ -0,0 +1,82 @@ +# Generated by Django 5.2.12 on 2026-04-09 22:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("openedx_authz", "0007_coursescope"), + ] + + operations = [ + migrations.CreateModel( + name="AuthzCourseAuthoringMigrationRun", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "migration_type", + models.CharField( + choices=[("forward", "Legacy to AuthZ"), ("rollback", "AuthZ to Legacy")], + help_text="Direction of migration: forward (legacy → authz) or rollback (authz → legacy)", + max_length=20, + ), + ), + ( + "scope_type", + models.CharField( + choices=[("course", "Course"), ("org", "Organization")], + help_text="Type of scope being migrated: course or organization", + max_length=20, + ), + ), + ( + "scope_key", + models.CharField( + help_text="Identifier for the scope (e.g., course-v1:edX+DemoX+DemoCourse or org name)", + max_length=255, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("running", "Running"), + ("completed", "Completed"), + ("skipped", "Skipped"), + ], + default="pending", + help_text="Current status of the migration run", + max_length=20, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, help_text="When the migration run was created")), + ( + "updated_at", + models.DateTimeField(auto_now=True, help_text="When the migration run was last updated"), + ), + ( + "completed_at", + models.DateTimeField(blank=True, help_text="When the migration run was completed", null=True), + ), + ( + "metadata", + models.JSONField( + blank=True, + default=dict, + help_text="Additional metadata about the migration run (e.g., counts, warnings)", + ), + ), + ], + options={ + "verbose_name": "Course Authoring Migration Run", + "verbose_name_plural": "Course Authoring Migration Runs", + "ordering": ["-created_at"], + "indexes": [ + models.Index(fields=["scope_type", "scope_key"], name="openedx_aut_scope_t_d43a35_idx"), + models.Index(fields=["status"], name="openedx_aut_status_e34b60_idx"), + models.Index(fields=["-created_at"], name="openedx_aut_created_ab3e0a_idx"), + ], + }, + ), + ] diff --git a/openedx_authz/models/__init__.py b/openedx_authz/models/__init__.py index 4f0318a2..446dc9f9 100644 --- a/openedx_authz/models/__init__.py +++ b/openedx_authz/models/__init__.py @@ -16,5 +16,6 @@ """ from openedx_authz.models.core import * +from openedx_authz.models.migrations import * from openedx_authz.models.scopes import * from openedx_authz.models.subjects import * diff --git a/openedx_authz/models/migrations.py b/openedx_authz/models/migrations.py new file mode 100644 index 00000000..77f46bed --- /dev/null +++ b/openedx_authz/models/migrations.py @@ -0,0 +1,130 @@ +"""Models for tracking migration runs between legacy and AuthZ systems. + +.. no_pii: +""" + +from django.db import models +from django.utils import timezone + + +class MigrationType(models.TextChoices): + """Direction of migration.""" + + FORWARD = "forward", "Legacy to AuthZ" + ROLLBACK = "rollback", "AuthZ to Legacy" + + +class Status(models.TextChoices): + """Status of the migration task.""" + + PENDING = "pending", "Pending" + RUNNING = "running", "Running" + COMPLETED = "completed", "Completed" + SKIPPED = "skipped", "Skipped" + + +class ScopeType(models.TextChoices): + """Type of scope being migrated.""" + + COURSE = "course", "Course" + ORG = "org", "Organization" + + +class AuthzCourseAuthoringMigrationRun(models.Model): + """Track the status of course authoring migration tasks. + + This model is used to track async migrations between the legacy + CourseAccessRole system and the new AuthZ system. + """ + + migration_type = models.CharField( + max_length=20, + choices=MigrationType, + help_text="Direction of migration: forward (legacy → authz) or rollback (authz → legacy)", + ) + + scope_type = models.CharField( + max_length=20, + choices=ScopeType, + help_text="Type of scope being migrated: course or organization", + ) + + scope_key = models.CharField( + max_length=255, + help_text="Identifier for the scope (e.g., course-v1:edX+DemoX+DemoCourse or org name)", + ) + + status = models.CharField( + max_length=20, + choices=Status, + default=Status.PENDING, + help_text="Current status of the migration run", + ) + + created_at = models.DateTimeField( + auto_now_add=True, + help_text="When the migration run was created", + ) + + updated_at = models.DateTimeField( + auto_now=True, + help_text="When the migration run was last updated", + ) + + completed_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the migration run was completed", + ) + + metadata = models.JSONField( + default=dict, + blank=True, + help_text="Additional metadata about the migration run (e.g., counts, warnings)", + ) + + class Meta: + verbose_name = "Course Authoring Migration Run" + verbose_name_plural = "Course Authoring Migration Runs" + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["scope_type", "scope_key"]), + models.Index(fields=["status"]), + models.Index(fields=["-created_at"]), + ] + + @classmethod + def create_pending(cls, migration_type, scope_type, scope_key, metadata=None) -> "AuthzCourseAuthoringMigrationRun": + """Create a pending migration run.""" + return cls.objects.create( + migration_type=migration_type, + scope_type=scope_type, + scope_key=scope_key, + metadata=metadata or {}, + ) + + def mark_running(self) -> None: + """Mark the migration run as running.""" + self.status = Status.RUNNING + self.save(update_fields=["status", "updated_at"]) + + def mark_skipped(self, *, reason=None) -> None: + """Mark the migration run as skipped.""" + self.status = Status.SKIPPED + if reason: + self.metadata = {**(self.metadata or {}), "skip_reason": reason} + self.save(update_fields=["status", "updated_at", "metadata"]) + return + self.save(update_fields=["status", "updated_at"]) + + def mark_completed(self, *, metadata_updates=None) -> None: + """Mark the migration run as completed.""" + self.status = Status.COMPLETED + self.completed_at = timezone.now() + if metadata_updates: + self.metadata = {**(self.metadata or {}), **metadata_updates} + self.save(update_fields=["status", "completed_at", "updated_at", "metadata"]) + + def __str__(self) -> str: + """Return a string representation of the migration run.""" + return f"[{self.id}] {self.migration_type} {self.scope_type}:{self.scope_key} {self.status}" diff --git a/openedx_authz/settings/common.py b/openedx_authz/settings/common.py index 81e060b8..fd171efb 100644 --- a/openedx_authz/settings/common.py +++ b/openedx_authz/settings/common.py @@ -54,3 +54,8 @@ def plugin_settings(settings): # This setting defines the logging level for the Casbin enforcer. if not hasattr(settings, "CASBIN_LOG_LEVEL"): settings.CASBIN_LOG_LEVEL = "WARNING" + + # Set default ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION if not already set. + # This setting defines whether to enable automatic course migration. + if not hasattr(settings, "ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION"): + settings.ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION = False diff --git a/openedx_authz/settings/test.py b/openedx_authz/settings/test.py index ba449092..89b0eb21 100644 --- a/openedx_authz/settings/test.py +++ b/openedx_authz/settings/test.py @@ -78,3 +78,6 @@ def plugin_settings(settings): # pylint: disable=unused-argument # Use stub model for testing instead of the real content_libraries app OPENEDX_AUTHZ_CONTENT_LIBRARY_MODEL = "stubs.ContentLibrary" OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL = "stubs.CourseOverview" + +# Migration settings +ENABLE_AUTOMATIC_AUTHZ_COURSE_AUTHORING_MIGRATION = False diff --git a/openedx_authz/tasks.py b/openedx_authz/tasks.py new file mode 100644 index 00000000..252dd0f4 --- /dev/null +++ b/openedx_authz/tasks.py @@ -0,0 +1,66 @@ +"""Celery tasks for course authoring migration. +These tasks handle asynchronous migration between legacy CourseAccessRole +and the new AuthZ system. +""" + +import logging + +from django.db import transaction + +# from celery import shared_task +from openedx_authz.engine.utils import migrate_authz_to_legacy_course_roles, migrate_legacy_course_roles_to_authz +from openedx_authz.models.migrations import MigrationType, ScopeType +from openedx_authz.models.subjects import UserSubject + +try: + from common.djangoapps.student.models import CourseAccessRole +except ImportError: + CourseAccessRole = None + +logger = logging.getLogger(__name__) + + +# @shared_task(bind=True) +def migrate_course_authoring_async( + # self, + migration_type: MigrationType | None, + scope_type: ScopeType, + scope_key: str, + course_id_list: list[str] | None = None, + org_id: str | None = None, + delete_after: bool = True, +): + """Asynchronously migrate course authoring roles between legacy and AuthZ systems. + Args: + migration_type: 'forward' (legacy→authz) or 'rollback' (authz→legacy) + scope_type: 'course' or 'org' + scope_key: Identifier for the scope + course_id_list: Optional list of course IDs to migrate (for course scope) + org_id: Optional organization ID to migrate (for org scope) + delete_after: Whether to delete source roles after successful migration + Returns: + dict: Migration result with status and metadata + """ + with transaction.atomic(): + if migration_type == MigrationType.FORWARD: + errors, successes = migrate_legacy_course_roles_to_authz( + course_access_role_model=CourseAccessRole, + course_id_list=course_id_list, + org_id=org_id, + delete_after_migration=delete_after, + ) + elif migration_type == MigrationType.ROLLBACK: + errors, successes = migrate_authz_to_legacy_course_roles( + course_access_role_model=CourseAccessRole, + user_subject_model=UserSubject, + course_id_list=course_id_list, + org_id=org_id, + delete_after_migration=delete_after, + ) + else: + raise ValueError(f"Invalid migration_type: {migration_type}") + + logger.info( + f"Completed {migration_type} migration for {scope_type}:{scope_key}. " + f"Successes: {len(successes)}, Errors: {len(errors)}" + ) diff --git a/openedx_authz/tests/test_migrations.py b/openedx_authz/tests/test_migrations.py index 17450ebb..fd22fe67 100644 --- a/openedx_authz/tests/test_migrations.py +++ b/openedx_authz/tests/test_migrations.py @@ -7,6 +7,7 @@ from django.core.management import CommandError, call_command from django.test import TestCase +from openedx_authz.api.data import OrgCourseOverviewGlobData from openedx_authz.api.users import batch_unassign_role_from_users, get_user_role_assignments_in_scope from openedx_authz.constants.roles import ( COURSE_ADMIN, @@ -1239,3 +1240,60 @@ def test_migrate_authz_to_legacy_course_roles_with_library_env(self): self.assertEqual(len(errors), 0) self.assertEqual(len(successes), 12) + + @patch("openedx_authz.api.data.CourseOverview", CourseOverview) + def test_migrate_org_level_scope_creates_org_glob_assignment(self): + """A CourseAccessRole with org set and blank course_id maps to an OrgCourseOverviewGlobData scope. + + Expected result: + User has a COURSE_ADMIN assignment under the org-level glob scope. + """ + org_short_name_new = f"{OBJECT_PREFIX}org2" + Organization.objects.create(name=f"{OBJECT_PREFIX}org2_full", short_name=org_short_name_new) + user = User.objects.create_user( + username=f"org_user_{OBJECT_PREFIX}", email=f"org_user_{OBJECT_PREFIX}@example.com" + ) + CourseAccessRole.objects.create(user=user, org=org_short_name_new, course_id="", role="instructor") + + _, _ = migrate_legacy_course_roles_to_authz( + CourseAccessRole, course_id_list=None, org_id=org_short_name_new, delete_after_migration=True + ) + AuthzEnforcer.get_enforcer().load_policy() + + org_scope = OrgCourseOverviewGlobData.build_external_key(org_short_name_new) + assignments = get_user_role_assignments_in_scope( + user_external_key=user.username, scope_external_key=org_scope + ) + self.assertEqual(len(assignments), 1) + self.assertEqual(assignments[0].roles[0], COURSE_ADMIN) + + @patch("openedx_authz.api.data.CourseOverview", CourseOverview) + def test_rollback_org_level_scope_creates_org_only_course_access_role(self): + """Rollback of an OrgCourseOverviewGlobData assignment recreates a CourseAccessRole with org only. + + Expected result: + The recreated entry has org set and course_id is None (org-wide, not course-specific). + """ + org_short_name_new = f"{OBJECT_PREFIX}org2" + Organization.objects.create(name=f"{OBJECT_PREFIX}org2_full", short_name=org_short_name_new) + user = User.objects.create_user( + username=f"org_user_{OBJECT_PREFIX}", email=f"org_user_{OBJECT_PREFIX}@example.com" + ) + CourseAccessRole.objects.create(user=user, org=org_short_name_new, course_id="", role="instructor") + + migrate_legacy_course_roles_to_authz( + CourseAccessRole, course_id_list=None, org_id=org_short_name_new, delete_after_migration=True + ) + AuthzEnforcer.get_enforcer().load_policy() + + errors, successes = migrate_authz_to_legacy_course_roles( + CourseAccessRole, UserSubject, course_id_list=None, org_id=org_short_name_new, delete_after_migration=True + ) + + self.assertEqual(len(errors), 0) + self.assertEqual(len(successes), 1) + + recreated = CourseAccessRole.objects.filter(user=user, org=org_short_name_new).first() + self.assertIsNotNone(recreated) + self.assertEqual(recreated.org, org_short_name_new) + self.assertIsNone(recreated.course_id) diff --git a/requirements/base.in b/requirements/base.in index 99d7082a..b89904bc 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -13,3 +13,4 @@ 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 edx-organizations # Organizations library for Open edX +celery # Asynchronous task queue diff --git a/requirements/base.txt b/requirements/base.txt index e9d01251..9331c19e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -4,12 +4,18 @@ # # pip-compile --output-file=requirements/base.txt requirements/base.in # +amqp==5.3.1 + # via kombu asgiref==3.9.1 # via django attrs==25.3.0 # via -r requirements/base.in +billiard==4.2.4 + # via celery casbin-django-orm-adapter==1.7.0 # via -r requirements/base.in +celery==5.6.3 + # via -r requirements/base.in certifi==2025.8.3 # via requests cffi==2.0.0 @@ -18,8 +24,19 @@ cffi==2.0.0 # pynacl charset-normalizer==3.4.3 # via requests -click==8.3.1 - # via edx-django-utils +click==8.3.2 + # via + # celery + # click-didyoumean + # click-plugins + # click-repl + # edx-django-utils +click-didyoumean==0.3.1 + # via celery +click-plugins==1.1.1.2 + # via celery +click-repl==0.3.0 + # via celery cryptography==46.0.2 # via pyjwt django==4.2.24 @@ -86,12 +103,18 @@ idna==3.10 # via requests inflection==0.5.1 # via drf-yasg +kombu==5.6.2 + # via celery openedx-atlas==0.7.0 # via -r requirements/base.in packaging==26.0 - # via drf-yasg + # via + # drf-yasg + # kombu pillow==12.1.1 # via edx-organizations +prompt-toolkit==3.0.52 + # via click-repl psutil==7.1.0 # via edx-django-utils pycasbin==2.2.0 @@ -108,6 +131,8 @@ pymongo==4.15.2 # via edx-opaque-keys pynacl==1.6.0 # via edx-django-utils +python-dateutil==2.9.0.post0 + # via celery pytz==2025.2 # via drf-yasg pyyaml==6.0.3 @@ -119,7 +144,9 @@ semantic-version==2.10.0 simpleeval==1.0.3 # via pycasbin six==1.17.0 - # via edx-ccx-keys + # via + # edx-ccx-keys + # python-dateutil sqlparse==0.5.3 # via django stevedore==5.5.0 @@ -128,10 +155,21 @@ stevedore==5.5.0 # edx-opaque-keys typing-extensions==4.15.0 # via edx-opaque-keys +tzdata==2026.1 + # via kombu +tzlocal==5.3.1 + # via celery uritemplate==4.2.0 # via drf-yasg urllib3==2.5.0 # via requests +vine==5.1.0 + # via + # amqp + # celery + # kombu +wcwidth==0.6.0 + # via prompt-toolkit # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/dev.txt b/requirements/dev.txt index b84f90f9..3401c5d2 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -4,6 +4,10 @@ # # pip-compile --output-file=requirements/dev.txt requirements/dev.in # +amqp==5.3.1 + # via + # -r requirements/quality.txt + # kombu asgiref==3.9.1 # via # -r requirements/quality.txt @@ -15,6 +19,10 @@ astroid==4.0.3 # pylint-celery attrs==25.3.0 # via -r requirements/quality.txt +billiard==4.2.4 + # via + # -r requirements/quality.txt + # celery build==1.4.2 # via # -r requirements/pip-tools.txt @@ -25,6 +33,8 @@ cachetools==6.2.6 # tox casbin-django-orm-adapter==1.7.0 # via -r requirements/quality.txt +celery==5.6.3 + # via -r requirements/quality.txt certifi==2025.8.3 # via # -r requirements/quality.txt @@ -43,19 +53,35 @@ charset-normalizer==3.4.3 # via # -r requirements/quality.txt # requests -click==8.3.1 +click==8.3.2 # via # -r requirements/pip-tools.txt # -r requirements/quality.txt + # celery + # click-didyoumean # click-log + # click-plugins + # click-repl # code-annotations # edx-django-utils # edx-lint # pip-tools +click-didyoumean==0.3.1 + # via + # -r requirements/quality.txt + # celery click-log==0.4.0 # via # -r requirements/quality.txt # edx-lint +click-plugins==1.1.1.2 + # via + # -r requirements/quality.txt + # celery +click-repl==0.3.0 + # via + # -r requirements/quality.txt + # celery code-annotations==2.3.0 # via # -r requirements/quality.txt @@ -188,6 +214,10 @@ jinja2==3.1.6 # -r requirements/quality.txt # code-annotations # diff-cover +kombu==5.6.2 + # via + # -r requirements/quality.txt + # celery lxml[html-clean]==6.0.1 # via # edx-i18n-tools @@ -211,6 +241,7 @@ packaging==26.0 # -r requirements/quality.txt # build # drf-yasg + # kombu # pyproject-api # pytest # tox @@ -240,6 +271,10 @@ pluggy==1.6.0 # tox polib==1.2.0 # via edx-i18n-tools +prompt-toolkit==3.0.52 + # via + # -r requirements/quality.txt + # click-repl psutil==7.1.0 # via # -r requirements/quality.txt @@ -310,6 +345,10 @@ pytest-cov==7.0.0 # via -r requirements/quality.txt pytest-django==4.11.1 # via -r requirements/quality.txt +python-dateutil==2.9.0.post0 + # via + # -r requirements/quality.txt + # celery python-slugify==8.0.4 # via # -r requirements/quality.txt @@ -343,6 +382,7 @@ six==1.17.0 # -r requirements/quality.txt # edx-ccx-keys # edx-lint + # python-dateutil snowballstemmer==3.0.1 # via # -r requirements/quality.txt @@ -371,6 +411,14 @@ typing-extensions==4.15.0 # via # -r requirements/quality.txt # edx-opaque-keys +tzdata==2026.1 + # via + # -r requirements/quality.txt + # kombu +tzlocal==5.3.1 + # via + # -r requirements/quality.txt + # celery uritemplate==4.2.0 # via # -r requirements/quality.txt @@ -379,10 +427,20 @@ urllib3==2.5.0 # via # -r requirements/quality.txt # requests +vine==5.1.0 + # via + # -r requirements/quality.txt + # amqp + # celery + # kombu virtualenv==20.36.1 # via # -r requirements/ci.txt # tox +wcwidth==0.6.0 + # via + # -r requirements/quality.txt + # prompt-toolkit wheel==0.46.3 # via # -r requirements/pip-tools.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index 5d74555a..c40a8255 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -8,6 +8,10 @@ accessible-pygments==0.0.5 # via pydata-sphinx-theme alabaster==1.0.0 # via sphinx +amqp==5.3.1 + # via + # -r requirements/test.txt + # kombu asgiref==3.9.1 # via # -r requirements/test.txt @@ -20,10 +24,16 @@ babel==2.17.0 # sphinx beautifulsoup4==4.13.5 # via pydata-sphinx-theme +billiard==4.2.4 + # via + # -r requirements/test.txt + # celery build==1.3.0 # via -r requirements/doc.in casbin-django-orm-adapter==1.7.0 # via -r requirements/test.txt +celery==5.6.3 + # via -r requirements/test.txt certifi==2025.8.3 # via # -r requirements/test.txt @@ -37,11 +47,27 @@ charset-normalizer==3.4.3 # via # -r requirements/test.txt # requests -click==8.3.1 +click==8.3.2 # via # -r requirements/test.txt + # celery + # click-didyoumean + # click-plugins + # click-repl # code-annotations # edx-django-utils +click-didyoumean==0.3.1 + # via + # -r requirements/test.txt + # celery +click-plugins==1.1.1.2 + # via + # -r requirements/test.txt + # celery +click-repl==0.3.0 + # via + # -r requirements/test.txt + # celery code-annotations==2.3.0 # via -r requirements/test.txt coverage[toml]==7.10.6 @@ -170,6 +196,10 @@ jinja2==3.1.6 # sphinx keyring==25.6.0 # via twine +kombu==5.6.2 + # via + # -r requirements/test.txt + # celery markdown-it-py==4.0.0 # via rich markupsafe==3.0.2 @@ -191,6 +221,7 @@ packaging==26.0 # -r requirements/test.txt # build # drf-yasg + # kombu # pydata-sphinx-theme # pytest # sphinx @@ -204,6 +235,10 @@ pluggy==1.6.0 # -r requirements/test.txt # pytest # pytest-cov +prompt-toolkit==3.0.52 + # via + # -r requirements/test.txt + # click-repl psutil==7.1.0 # via # -r requirements/test.txt @@ -252,6 +287,10 @@ pytest-cov==7.0.0 # via -r requirements/test.txt pytest-django==4.11.1 # via -r requirements/test.txt +python-dateutil==2.9.0.post0 + # via + # -r requirements/test.txt + # celery python-slugify==8.0.4 # via # -r requirements/test.txt @@ -299,6 +338,7 @@ six==1.17.0 # via # -r requirements/test.txt # edx-ccx-keys + # python-dateutil snowballstemmer==3.0.1 # via sphinx soupsieve==2.8 @@ -345,6 +385,14 @@ typing-extensions==4.15.0 # beautifulsoup4 # edx-opaque-keys # pydata-sphinx-theme +tzdata==2026.1 + # via + # -r requirements/test.txt + # kombu +tzlocal==5.3.1 + # via + # -r requirements/test.txt + # celery uritemplate==4.2.0 # via # -r requirements/test.txt @@ -354,6 +402,16 @@ urllib3==2.5.0 # -r requirements/test.txt # requests # twine +vine==5.1.0 + # via + # -r requirements/test.txt + # amqp + # celery + # kombu +wcwidth==0.6.0 + # via + # -r requirements/test.txt + # prompt-toolkit # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index d391764e..a5c65a01 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -6,7 +6,7 @@ # build==1.4.2 # via pip-tools -click==8.3.1 +click==8.3.2 # via pip-tools packaging==26.0 # via diff --git a/requirements/quality.txt b/requirements/quality.txt index 3c3af9d2..3a2acb01 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -4,6 +4,10 @@ # # pip-compile --output-file=requirements/quality.txt requirements/quality.in # +amqp==5.3.1 + # via + # -r requirements/test.txt + # kombu asgiref==3.9.1 # via # -r requirements/test.txt @@ -14,8 +18,14 @@ astroid==4.0.3 # pylint-celery attrs==25.3.0 # via -r requirements/test.txt +billiard==4.2.4 + # via + # -r requirements/test.txt + # celery casbin-django-orm-adapter==1.7.0 # via -r requirements/test.txt +celery==5.6.3 + # via -r requirements/test.txt certifi==2025.8.3 # via # -r requirements/test.txt @@ -29,15 +39,31 @@ charset-normalizer==3.4.3 # via # -r requirements/test.txt # requests -click==8.3.1 +click==8.3.2 # via # -r requirements/test.txt + # celery + # click-didyoumean # click-log + # click-plugins + # click-repl # code-annotations # edx-django-utils # edx-lint +click-didyoumean==0.3.1 + # via + # -r requirements/test.txt + # celery click-log==0.4.0 # via edx-lint +click-plugins==1.1.1.2 + # via + # -r requirements/test.txt + # celery +click-repl==0.3.0 + # via + # -r requirements/test.txt + # celery code-annotations==2.3.0 # via # -r requirements/test.txt @@ -147,6 +173,10 @@ jinja2==3.1.6 # via # -r requirements/test.txt # code-annotations +kombu==5.6.2 + # via + # -r requirements/test.txt + # celery markupsafe==3.0.2 # via # -r requirements/test.txt @@ -159,6 +189,7 @@ packaging==26.0 # via # -r requirements/test.txt # drf-yasg + # kombu # pytest pillow==12.1.1 # via @@ -171,6 +202,10 @@ pluggy==1.6.0 # -r requirements/test.txt # pytest # pytest-cov +prompt-toolkit==3.0.52 + # via + # -r requirements/test.txt + # click-repl psutil==7.1.0 # via # -r requirements/test.txt @@ -225,6 +260,10 @@ pytest-cov==7.0.0 # via -r requirements/test.txt pytest-django==4.11.1 # via -r requirements/test.txt +python-dateutil==2.9.0.post0 + # via + # -r requirements/test.txt + # celery python-slugify==8.0.4 # via # -r requirements/test.txt @@ -257,6 +296,7 @@ six==1.17.0 # -r requirements/test.txt # edx-ccx-keys # edx-lint + # python-dateutil snowballstemmer==3.0.1 # via pydocstyle sqlparse==0.5.3 @@ -279,6 +319,14 @@ typing-extensions==4.15.0 # via # -r requirements/test.txt # edx-opaque-keys +tzdata==2026.1 + # via + # -r requirements/test.txt + # kombu +tzlocal==5.3.1 + # via + # -r requirements/test.txt + # celery uritemplate==4.2.0 # via # -r requirements/test.txt @@ -287,6 +335,16 @@ urllib3==2.5.0 # via # -r requirements/test.txt # requests +vine==5.1.0 + # via + # -r requirements/test.txt + # amqp + # celery + # kombu +wcwidth==0.6.0 + # via + # -r requirements/test.txt + # prompt-toolkit # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/test.txt b/requirements/test.txt index 244228fb..eda4ebff 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -4,14 +4,24 @@ # # pip-compile --output-file=requirements/test.txt requirements/test.in # +amqp==5.3.1 + # via + # -r requirements/base.txt + # kombu asgiref==3.9.1 # via # -r requirements/base.txt # django attrs==25.3.0 # via -r requirements/base.txt +billiard==4.2.4 + # via + # -r requirements/base.txt + # celery casbin-django-orm-adapter==1.7.0 # via -r requirements/base.txt +celery==5.6.3 + # via -r requirements/base.txt certifi==2025.8.3 # via # -r requirements/base.txt @@ -25,11 +35,27 @@ charset-normalizer==3.4.3 # via # -r requirements/base.txt # requests -click==8.3.1 +click==8.3.2 # via # -r requirements/base.txt + # celery + # click-didyoumean + # click-plugins + # click-repl # code-annotations # edx-django-utils +click-didyoumean==0.3.1 + # via + # -r requirements/base.txt + # celery +click-plugins==1.1.1.2 + # via + # -r requirements/base.txt + # celery +click-repl==0.3.0 + # via + # -r requirements/base.txt + # celery code-annotations==2.3.0 # via -r requirements/test.in coverage[toml]==7.10.6 @@ -124,6 +150,10 @@ iniconfig==2.1.0 # via pytest jinja2==3.1.6 # via code-annotations +kombu==5.6.2 + # via + # -r requirements/base.txt + # celery markupsafe==3.0.2 # via jinja2 openedx-atlas==0.7.0 @@ -132,6 +162,7 @@ packaging==26.0 # via # -r requirements/base.txt # drf-yasg + # kombu # pytest pillow==12.1.1 # via @@ -141,6 +172,10 @@ pluggy==1.6.0 # via # pytest # pytest-cov +prompt-toolkit==3.0.52 + # via + # -r requirements/base.txt + # click-repl psutil==7.1.0 # via # -r requirements/base.txt @@ -176,6 +211,10 @@ pytest-cov==7.0.0 # via -r requirements/test.in pytest-django==4.11.1 # via -r requirements/test.in +python-dateutil==2.9.0.post0 + # via + # -r requirements/base.txt + # celery python-slugify==8.0.4 # via code-annotations pytz==2025.2 @@ -203,6 +242,7 @@ six==1.17.0 # via # -r requirements/base.txt # edx-ccx-keys + # python-dateutil sqlparse==0.5.3 # via # -r requirements/base.txt @@ -219,6 +259,14 @@ typing-extensions==4.15.0 # via # -r requirements/base.txt # edx-opaque-keys +tzdata==2026.1 + # via + # -r requirements/base.txt + # kombu +tzlocal==5.3.1 + # via + # -r requirements/base.txt + # celery uritemplate==4.2.0 # via # -r requirements/base.txt @@ -227,6 +275,16 @@ urllib3==2.5.0 # via # -r requirements/base.txt # requests +vine==5.1.0 + # via + # -r requirements/base.txt + # amqp + # celery + # kombu +wcwidth==0.6.0 + # via + # -r requirements/base.txt + # prompt-toolkit # The following packages are considered to be unsafe in a requirements file: # setuptools