Merge branch 'master' into final-dj52

This commit is contained in:
Usama Sadiq
2025-10-07 09:30:06 +05:00
committed by GitHub
41 changed files with 1309 additions and 405 deletions

View File

@@ -1,23 +0,0 @@
version: '3'
services:
mysql:
image: mysql:5.7
container_name: edx.devstack.mysql80
ports:
- '3306:3306'
environment:
MYSQL_ROOT_PASSWORD: ""
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
volumes:
- ./init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
timeout: 20s
retries: 10
edxapp:
image: edxops/edxapp:latest
command: bash -c 'source /edx/app/edxapp/edxapp_env && cd /edx/app/edxapp/edx-platform/ && make migrate'
volumes:
- ../../:/edx/app/edxapp/edx-platform
depends_on:
- mysql

View File

@@ -1,3 +0,0 @@
CREATE DATABASE IF NOT EXISTS `edxapp`;
CREATE DATABASE IF NOT EXISTS `edxapp_csmh`;
GRANT ALL PRIVILEGES ON *.* TO 'edxapp001'@'%' IDENTIFIED BY 'password';

View File

@@ -46,6 +46,7 @@ class ModulestoreMigrationSerializer(serializers.ModelSerializer):
help_text="The target collection slug within the library to import into. Optional.",
required=False,
allow_blank=True,
default=None,
)
forward_source_to_target = serializers.BooleanField(
help_text="Forward references of this block source over to the target of this block migration.",

View File

@@ -9,6 +9,7 @@ import typing as t
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from itertools import groupby
from celery import shared_task
from celery.utils.log import get_task_logger
@@ -20,8 +21,11 @@ from lxml.etree import _ElementTree as XmlTree
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import (
CourseLocator, LibraryLocator,
LibraryLocatorV2, LibraryUsageLocatorV2, LibraryContainerLocator
CourseLocator,
LibraryContainerLocator,
LibraryLocator,
LibraryLocatorV2,
LibraryUsageLocatorV2
)
from openedx_learning.api import authoring as authoring_api
from openedx_learning.api.authoring_models import (
@@ -30,21 +34,20 @@ from openedx_learning.api.authoring_models import (
ComponentType,
LearningPackage,
PublishableEntity,
PublishableEntityVersion,
PublishableEntityVersion
)
from user_tasks.tasks import UserTask, UserTaskStatus
from openedx.core.djangoapps.content_libraries.api import ContainerType, get_library
from common.djangoapps.split_modulestore_django.models import SplitModulestoreCourseIndex
from openedx.core.djangoapps.content_libraries import api as libraries_api
from openedx.core.djangoapps.content_libraries.api import ContainerType, get_library
from openedx.core.djangoapps.content_staging import api as staging_api
from xmodule.modulestore import exceptions as modulestore_exceptions
from xmodule.modulestore.django import modulestore
from common.djangoapps.split_modulestore_django.models import SplitModulestoreCourseIndex
from .constants import CONTENT_STAGING_PURPOSE_TEMPLATE
from .data import CompositionLevel, RepeatHandlingStrategy
from .models import ModulestoreSource, ModulestoreMigration, ModulestoreBlockSource, ModulestoreBlockMigration
from .models import ModulestoreBlockMigration, ModulestoreBlockSource, ModulestoreMigration, ModulestoreSource
log = get_task_logger(__name__)
@@ -89,7 +92,7 @@ class _MigrationContext:
Context for the migration process.
"""
existing_source_to_target_keys: dict[ # Note: It's intended to be mutable to reflect changes during migration.
UsageKey, PublishableEntity
UsageKey, list[PublishableEntity]
]
target_package_id: int
target_library_key: LibraryLocatorV2
@@ -105,16 +108,30 @@ class _MigrationContext:
return source_key in self.existing_source_to_target_keys
def get_existing_target(self, source_key: UsageKey) -> PublishableEntity:
return self.existing_source_to_target_keys[source_key]
"""
Get the target entity for a given source key.
If the source key is already migrated, return the FIRST target entity.
If the source key is not found, raise a KeyError.
"""
if source_key not in self.existing_source_to_target_keys:
raise KeyError(f"Source key {source_key} not found in existing source to target keys")
# NOTE: This is a list of PublishableEntities, but we always return the first one.
return self.existing_source_to_target_keys[source_key][0]
def add_migration(self, source_key: UsageKey, target: PublishableEntity) -> None:
"""Update the context with a new migration (keeps it current)"""
self.existing_source_to_target_keys[source_key] = target
if source_key not in self.existing_source_to_target_keys:
self.existing_source_to_target_keys[source_key] = [target]
else:
self.existing_source_to_target_keys[source_key].append(target)
def get_existing_target_entity_keys(self, base_key: str) -> set[str]:
return set(
publishable_entity.key for _, publishable_entity in
self.existing_source_to_target_keys.items()
publishable_entity.key
for publishable_entity_list in self.existing_source_to_target_keys.values()
for publishable_entity in publishable_entity_list
if publishable_entity.key.startswith(base_key)
)
@@ -285,10 +302,13 @@ def migrate_from_modulestore(
# a given LearningPackage.
# We use this mapping to ensure that we don't create duplicate
# PublishableEntities during the migration process for a given LearningPackage.
existing_source_to_target_keys: dict[UsageKey, list[PublishableEntity]] = {}
modulestore_blocks = (
ModulestoreBlockMigration.objects.filter(overall_migration__target=migration.target.id).order_by("source__key")
)
existing_source_to_target_keys = {
block.source.key: block.target for block in ModulestoreBlockMigration.objects.filter(
overall_migration__target=migration.target.id
)
source_key: list(block.target for block in group) for source_key, group in groupby(
modulestore_blocks, key=lambda x: x.source.key)
}
migration_context = _MigrationContext(
@@ -657,7 +677,7 @@ def _get_distinct_target_usage_key(
# Check if we already processed this block and we are not forking. If we are forking, we will
# want a new target key.
if context.is_already_migrated(source_key) and not context.should_fork_strategy:
log.debug(f"Block {source_key} already exists, reusing existing target")
log.debug(f"Block {source_key} already exists, reusing first existing target")
existing_target = context.get_existing_target(source_key)
block_id = existing_target.component.local_key

View File

@@ -276,6 +276,43 @@ class TestModulestoreMigratorAPI(LibraryTestCase):
)
assert second_component.display_name == "Updated Block"
# Update the block again, changing its name
library_block.display_name = "Updated Block Again"
self.store.update_item(library_block, user.id)
# Migrate again using the Fork strategy
api.start_migration_to_library(
user=user,
source_key=source.key,
target_library_key=self.library_v2.library_key,
composition_level=CompositionLevel.Component.value,
repeat_handling_strategy=RepeatHandlingStrategy.Fork.value,
preserve_url_slugs=True,
forward_source_to_target=False,
)
modulestoremigration = ModulestoreMigration.objects.last()
assert modulestoremigration is not None
assert modulestoremigration.repeat_handling_strategy == RepeatHandlingStrategy.Fork.value
migrated_components_fork = lib_api.get_library_components(self.library_v2.library_key)
assert len(migrated_components_fork) == 3
first_component = lib_api.LibraryXBlockMetadata.from_component(
self.library_v2.library_key, migrated_components_fork[0]
)
assert first_component.display_name == "Original Block"
second_component = lib_api.LibraryXBlockMetadata.from_component(
self.library_v2.library_key, migrated_components_fork[1]
)
assert second_component.display_name == "Updated Block"
third_component = lib_api.LibraryXBlockMetadata.from_component(
self.library_v2.library_key, migrated_components_fork[2]
)
assert third_component.display_name == "Updated Block Again"
def test_get_migration_info(self):
"""
Test that the API can retrieve migration info.

View File

@@ -447,7 +447,7 @@ class TestMigrateFromModulestore(ModuleStoreTestCase):
title="test_problem"
)
context.existing_source_to_target_keys[source_key] = first_result.entity
context.existing_source_to_target_keys[source_key] = [first_result.entity]
second_result = _migrate_component(
context=context,
@@ -489,7 +489,7 @@ class TestMigrateFromModulestore(ModuleStoreTestCase):
title="test_problem"
)
context.existing_source_to_target_keys[source_key_1] = first_result.entity
context.existing_source_to_target_keys[source_key_1] = [first_result.entity]
second_result = _migrate_component(
context=context,
@@ -527,7 +527,7 @@ class TestMigrateFromModulestore(ModuleStoreTestCase):
title="original"
)
context.existing_source_to_target_keys[source_key] = first_result.entity
context.existing_source_to_target_keys[source_key] = [first_result.entity]
updated_olx = '<problem display_name="Updated"><multiplechoiceresponse></multiplechoiceresponse></problem>'
second_result = _migrate_component(
@@ -708,7 +708,7 @@ class TestMigrateFromModulestore(ModuleStoreTestCase):
title="test_problem"
)
context.existing_source_to_target_keys[source_key] = first_result.entity
context.existing_source_to_target_keys[source_key] = [first_result.entity]
second_result = _migrate_component(
context=context,
@@ -863,7 +863,7 @@ class TestMigrateFromModulestore(ModuleStoreTestCase):
children=[],
)
context.existing_source_to_target_keys[source_key] = first_result.entity
context.existing_source_to_target_keys[source_key] = [first_result.entity]
second_result = _migrate_container(
context=context,
@@ -909,7 +909,7 @@ class TestMigrateFromModulestore(ModuleStoreTestCase):
children=[],
)
context.existing_source_to_target_keys[source_key_1] = first_result.entity
context.existing_source_to_target_keys[source_key_1] = [first_result.entity]
second_result = _migrate_container(
context=context,
@@ -969,7 +969,7 @@ class TestMigrateFromModulestore(ModuleStoreTestCase):
children=[],
)
context.existing_source_to_target_keys[source_key] = first_result.entity
context.existing_source_to_target_keys[source_key] = [first_result.entity]
second_result = _migrate_container(
context=context,

View File

@@ -139,12 +139,6 @@ if STATIC_ROOT_BASE:
DATA_DIR = path(DATA_DIR)
ALLOWED_HOSTS = [
# TODO: bbeggs remove this before prod, temp fix to get load testing running
"*",
CMS_BASE,
]
# Cache used for location mapping -- called many times with the same key/value
# in a given request.
if 'loc_cache' not in CACHES:

View File

@@ -533,15 +533,19 @@ class UpstreamTestCase(ModuleStoreTestCase):
"""
Does sever_upstream_link correctly disconnect a block from its upstream?
"""
# Start with a course block that is linked+synced to a content library block.
# Start with a course block that is linked+synced to a content library block
# and has a customizred title.
downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
sync_from_upstream_block(downstream, self.user)
downstream.display_name = "Downstream Title"
save_xblock_with_callback(downstream, self.user)
# (sanity checks)
assert downstream.upstream == str(self.upstream_key)
assert downstream.upstream_version == 2
assert downstream.upstream_display_name == "Upstream Title V2"
assert downstream.display_name == "Upstream Title V2"
assert downstream.display_name == "Downstream Title"
assert downstream.downstream_customized == ["display_name"]
assert downstream.data == "<html><body>Upstream content V2</body></html>"
assert downstream.copied_from_block is None
@@ -552,14 +556,21 @@ class UpstreamTestCase(ModuleStoreTestCase):
assert downstream.upstream is None
assert downstream.upstream_version is None
assert downstream.upstream_display_name is None
assert downstream.downstream_customized == []
# BUT, the content which was synced into the upstream remains.
assert downstream.display_name == "Upstream Title V2"
# BUT, the content remains.
assert downstream.display_name == "Downstream Title"
assert downstream.data == "<html><body>Upstream content V2</body></html>"
# AND, we have recorded the old upstream as our copied_from_block.
assert downstream.copied_from_block == str(self.upstream_key)
# Finally... unlike an upstream-linked block, our unlinked block should not
# have its downstream_customized updated when the title changes.
downstream.display_name = "Downstream Title II"
save_xblock_with_callback(downstream, self.user)
assert downstream.downstream_customized == []
def test_sync_library_block_tags(self):
upstream_lib_block_key = libs.create_library_block(self.library.key, "html", "upstream").usage_key
upstream_lib_block = xblock.load_block(upstream_lib_block_key, self.user)

View File

@@ -386,6 +386,7 @@ def sever_upstream_link(downstream: XBlock) -> list[XBlock]:
downstream.copied_from_block = downstream.upstream
downstream.upstream = None
downstream.upstream_version = None
downstream.downstream_customized = []
for _, fetched_upstream_field in downstream.get_customizable_fields().items():
# Downstream-only fields don't have an upstream fetch field
if fetched_upstream_field is None:
@@ -527,6 +528,10 @@ class UpstreamSyncMixin(XBlockMixin):
Update `downstream_customized` when a customizable field is modified.
"""
super().editor_saved(user, old_metadata, old_content)
if not self.upstream:
# If a block does not have an upstream, then we do not need to track its
# customizations.
return
customizable_fields = self.get_customizable_fields()
new_data = (
self.get_explicitly_set_fields_by_scope(Scope.settings)

View File

@@ -44,7 +44,7 @@ from eventtracking import tracker
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField, LearningContextKeyField
from pytz import UTC, timezone
from user_util import user_util
from openedx.core.lib import user_util
import openedx.core.djangoapps.django_comment_common.comment_client as cc
from common.djangoapps.util.model_utils import emit_field_changed_events, get_changed_fields_dict

View File

@@ -10,7 +10,7 @@ from django.utils.translation import gettext_lazy as _
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField
from simple_history.models import HistoricalRecords
from user_util import user_util
from openedx.core.lib import user_util
from common.djangoapps.student.models import CourseEnrollment

View File

@@ -141,11 +141,6 @@ SESSION_COOKIE_SAMESITE = DCS_SESSION_COOKIE_SAMESITE
for feature, value in _YAML_TOKENS.get('FEATURES', {}).items():
FEATURES[feature] = value
ALLOWED_HOSTS = [
"*",
_YAML_TOKENS.get('LMS_BASE'),
]
# Cache used for location mapping -- called many times with the same key/value
# in a given request.
if 'loc_cache' not in CACHES:

View File

@@ -41,9 +41,10 @@ could be promoted to the core XBlock API and made generic.
"""
from __future__ import annotations
from dataclasses import dataclass, field as dataclass_field
from datetime import datetime
import logging
from dataclasses import dataclass
from dataclasses import field as dataclass_field
from datetime import datetime
from django.conf import settings
from django.contrib.auth.models import AbstractUser, AnonymousUser, Group
@@ -53,29 +54,24 @@ from django.db import IntegrityError, transaction
from django.db.models import Q, QuerySet
from django.utils.translation import gettext as _
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
from openedx_events.content_authoring.data import (
ContentLibraryData,
)
from openedx_events.content_authoring.data import ContentLibraryData
from openedx_events.content_authoring.signals import (
CONTENT_LIBRARY_CREATED,
CONTENT_LIBRARY_DELETED,
CONTENT_LIBRARY_UPDATED,
CONTENT_LIBRARY_UPDATED
)
from openedx_learning.api import authoring as authoring_api
from openedx_learning.api.authoring_models import Component
from organizations.models import Organization
from user_tasks.models import UserTaskArtifact, UserTaskStatus
from xblock.core import XBlock
from openedx.core.types import User as UserType
from .. import permissions
from .. import permissions, tasks
from ..constants import ALL_RIGHTS_RESERVED
from ..models import ContentLibrary, ContentLibraryPermission
from .. import tasks
from .exceptions import (
LibraryAlreadyExists,
LibraryPermissionIntegrityError,
)
from .exceptions import LibraryAlreadyExists, LibraryPermissionIntegrityError
log = logging.getLogger(__name__)
@@ -105,6 +101,7 @@ __all__ = [
"get_allowed_block_types",
"publish_changes",
"revert_changes",
"get_backup_task_status",
]
@@ -692,3 +689,30 @@ def revert_changes(library_key: LibraryLocatorV2, user_id: int | None = None) ->
# Call the event handlers as needed.
tasks.wait_for_post_revert_events(draft_change_log, library_key)
def get_backup_task_status(
user_id: int,
task_id: str
) -> dict | None:
"""
Get the status of a library backup task.
Returns a dictionary with the following keys:
- state: One of "Pending", "Exporting", "Succeeded", "Failed"
- url: If state is "Succeeded", the URL where the exported .zip file can be downloaded. Otherwise, None.
If no task is found, returns None.
"""
try:
task_status = UserTaskStatus.objects.get(task_id=task_id, user_id=user_id)
except UserTaskStatus.DoesNotExist:
return None
result = {'state': task_status.state, 'url': None}
if task_status.state == UserTaskStatus.SUCCEEDED:
artifact = UserTaskArtifact.objects.get(status=task_status, name='Output')
result['url'] = artifact.file.storage.url(artifact.file.name)
return result

View File

@@ -66,6 +66,7 @@ import itertools
import json
import logging
import edx_api_doc_tools as apidocs
from django.conf import settings
from django.contrib.auth import authenticate, get_user_model, login
from django.contrib.auth.models import Group
@@ -78,14 +79,12 @@ from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.base import TemplateResponseMixin, View
from drf_yasg.utils import swagger_auto_schema
from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, DjangoMessageLaunch, DjangoOIDCLogin
from pylti1p3.exception import LtiException, OIDCException
import edx_api_doc_tools as apidocs
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
from organizations.api import ensure_organization
from organizations.exceptions import InvalidOrganizationException
from organizations.models import Organization
from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, DjangoMessageLaunch, DjangoOIDCLogin
from pylti1p3.exception import LtiException, OIDCException
from rest_framework import status
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
from rest_framework.generics import GenericAPIView
@@ -93,12 +92,15 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet
import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers
from cms.djangoapps.contentstore.views.course import (
get_allowed_organizations_for_libraries,
user_can_create_organizations,
user_can_create_organizations
)
from openedx.core.djangoapps.content_libraries import api, permissions
from openedx.core.djangoapps.content_libraries.api.libraries import get_backup_task_status
from openedx.core.djangoapps.content_libraries.rest_api.serializers import (
ContentLibraryAddPermissionByEmailSerializer,
ContentLibraryBlockImportTaskCreateSerializer,
ContentLibraryBlockImportTaskSerializer,
ContentLibraryFilterSerializer,
@@ -106,20 +108,20 @@ from openedx.core.djangoapps.content_libraries.rest_api.serializers import (
ContentLibraryPermissionLevelSerializer,
ContentLibraryPermissionSerializer,
ContentLibraryUpdateSerializer,
LibraryBackupResponseSerializer,
LibraryBackupTaskStatusSerializer,
LibraryXBlockCreationSerializer,
LibraryXBlockMetadataSerializer,
LibraryXBlockTypeSerializer,
ContentLibraryAddPermissionByEmailSerializer,
PublishableItemSerializer,
PublishableItemSerializer
)
import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers
from openedx.core.lib.api.view_utils import view_auth_classes
from openedx.core.djangoapps.content_libraries.tasks import backup_library
from openedx.core.djangoapps.safe_sessions.middleware import mark_user_change_as_expected
from openedx.core.djangoapps.xblock import api as xblock_api
from openedx.core.lib.api.view_utils import view_auth_classes
from .utils import convert_exceptions
from ..models import ContentLibrary, LtiGradedResource, LtiProfile
from .utils import convert_exceptions
User = get_user_model()
log = logging.getLogger(__name__)
@@ -685,6 +687,109 @@ class LibraryImportTaskViewSet(GenericViewSet):
return Response(ContentLibraryBlockImportTaskSerializer(import_task).data)
# Library Backup Views
# ====================
@method_decorator(non_atomic_requests, name="dispatch")
@view_auth_classes()
class LibraryBackupView(APIView):
"""
**Use Case**
* Start an asynchronous task to back up the content of a library to a .zip file
* Get a status on an asynchronous export task
**Example Requests**
POST /api/libraries/v2/{library_id}/backup/
GET /api/libraries/v2/{library_id}/backup/?task_id={task_id}
**POST Response Values**
If the import task is started successfully, an HTTP 200 "OK" response is
returned.
The HTTP 200 response has the following values:
* task_id: UUID of the created task, usable for checking status
**Example POST Response**
{
"task_id": "7069b95b-ccea-4214-b6db-e00f27065bf7"
}
**GET Parameters**
A GET request must include the following parameters:
* task_id: (required) The UUID of the task to check.
**GET Response Values**
If the import task is found successfully by the UUID provided, an HTTP
200 "OK" response is returned.
The HTTP 200 response has the following values:
* state: String description of the state of the task.
Possible states: "Pending", "Exporting", "Succeeded", "Failed".
* url: (may be null) If the task is complete, a URL to download the .zip file
**Example GET Response**
{
"state": "Succeeded",
"url": "/media/user_tasks/2025/10/03/lib-wgu-csprob-2025-10-03-153633.zip"
}
"""
@apidocs.schema(
body=None,
responses={200: LibraryBackupResponseSerializer}
)
@convert_exceptions
def post(self, request, lib_key_str):
"""
Start backup task for the specified library.
"""
library_key = LibraryLocatorV2.from_string(lib_key_str)
# Using CAN_EDIT_THIS_CONTENT_LIBRARY permission for now. This should eventually become its own permission
api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
async_result = backup_library.delay(request.user.id, str(library_key))
result = {'task_id': async_result.task_id}
return Response(LibraryBackupResponseSerializer(result).data)
@apidocs.schema(
parameters=[
apidocs.query_parameter(
'task_id',
str,
description="The ID of the backup task to retrieve."
),
],
responses={200: LibraryBackupTaskStatusSerializer}
)
@convert_exceptions
def get(self, request, lib_key_str):
"""
Get the status of the specified backup task for the specified library.
"""
library_key = LibraryLocatorV2.from_string(lib_key_str)
# Using CAN_EDIT_THIS_CONTENT_LIBRARY permission for now. This should eventually become its own permission
api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
task_id = request.query_params.get('task_id', None)
if not task_id:
raise ValidationError(detail={'task_id': _('This field is required.')})
result = get_backup_task_status(request.user.id, task_id)
if not result:
raise NotFound(detail="No backup found for this library.")
return Response(LibraryBackupTaskStatusSerializer(result).data)
# LTI 1.3 Views
# =============

View File

@@ -3,26 +3,22 @@ Serializers for the content libraries REST API
"""
# pylint: disable=abstract-method
from django.core.validators import validate_unicode_slug
from opaque_keys import InvalidKeyError, OpaqueKey
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2
from openedx_learning.api.authoring_models import Collection
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from opaque_keys import OpaqueKey
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2
from opaque_keys import InvalidKeyError
from openedx_learning.api.authoring_models import Collection
from openedx.core.djangoapps.content_libraries.api.containers import ContainerType
from openedx.core.djangoapps.content_libraries.constants import (
ALL_RIGHTS_RESERVED,
LICENSE_OPTIONS,
)
from openedx.core.djangoapps.content_libraries.constants import ALL_RIGHTS_RESERVED, LICENSE_OPTIONS
from openedx.core.djangoapps.content_libraries.models import (
ContentLibraryPermission, ContentLibraryBlockImportTask,
ContentLibrary
ContentLibrary,
ContentLibraryBlockImportTask,
ContentLibraryPermission
)
from openedx.core.lib.api.serializers import CourseKeyField
from .. import permissions
from .. import permissions
DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
@@ -416,3 +412,18 @@ class ContainerHierarchySerializer(serializers.Serializer):
units = serializers.ListField(child=ContainerHierarchyMemberSerializer(), allow_empty=True)
components = serializers.ListField(child=ContainerHierarchyMemberSerializer(), allow_empty=True)
object_key = OpaqueKeySerializer()
class LibraryBackupResponseSerializer(serializers.Serializer):
"""
Serializer for the response after requesting a backup of a content library.
"""
task_id = serializers.CharField()
class LibraryBackupTaskStatusSerializer(serializers.Serializer):
"""
Serializer for checking the status of a library backup task.
"""
state = serializers.CharField()
url = serializers.URLField(allow_null=True)

View File

@@ -17,37 +17,44 @@ Architecture note:
from __future__ import annotations
import logging
import os
from datetime import datetime
from tempfile import mkdtemp
from celery import shared_task
from celery_utils.logged_task import LoggedTask
from celery.utils.log import get_task_logger
from edx_django_utils.monitoring import set_code_owner_attribute, set_code_owner_attribute_from_module
from celery_utils.logged_task import LoggedTask
from django.core.files import File
from django.utils.text import slugify
from edx_django_utils.monitoring import (
set_code_owner_attribute,
set_code_owner_attribute_from_module,
set_custom_attribute
)
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import (
BlockUsageLocator,
LibraryCollectionLocator,
LibraryContainerLocator,
LibraryLocatorV2,
)
from openedx_learning.api import authoring as authoring_api
from openedx_learning.api.authoring_models import DraftChangeLog, PublishLog
from openedx_events.content_authoring.data import (
LibraryBlockData,
LibraryCollectionData,
LibraryContainerData,
LibraryLocatorV2
)
from openedx_events.content_authoring.data import LibraryBlockData, LibraryCollectionData, LibraryContainerData
from openedx_events.content_authoring.signals import (
LIBRARY_BLOCK_CREATED,
LIBRARY_BLOCK_DELETED,
LIBRARY_BLOCK_UPDATED,
LIBRARY_BLOCK_PUBLISHED,
LIBRARY_BLOCK_UPDATED,
LIBRARY_COLLECTION_UPDATED,
LIBRARY_CONTAINER_CREATED,
LIBRARY_CONTAINER_DELETED,
LIBRARY_CONTAINER_UPDATED,
LIBRARY_CONTAINER_PUBLISHED,
LIBRARY_CONTAINER_UPDATED
)
from openedx_learning.api import authoring as authoring_api
from openedx_learning.api.authoring import create_zip_file as create_lib_zip_file
from openedx_learning.api.authoring_models import DraftChangeLog, PublishLog
from path import Path
from user_tasks.models import UserTaskArtifact
from user_tasks.tasks import UserTask, UserTaskStatus
from xblock.fields import Scope
@@ -477,3 +484,66 @@ def _copy_overrides(
dest_block=store.get_item(dest_child_key),
)
store.update_item(dest_block, user_id)
class LibraryBackupTask(UserTask): # pylint: disable=abstract-method
"""
Base class for tasks related with Library backup functionality.
"""
@classmethod
def generate_name(cls, arguments_dict) -> str:
"""
Create a name for this particular backup task instance.
Should be both:
a. semi human-friendly
b. something we can query in order to determine whether the library has a task in progress
Arguments:
arguments_dict (dict): The arguments given to the task function
Returns:
str: The generated name
"""
key = arguments_dict['library_key_str']
return f'Backup of {key}'
@shared_task(base=LibraryBackupTask, bind=True)
# Note: The decorator @set_code_owner_attribute cannot be used here because the UserTaskMixin
# does stack inspection and can't handle additional decorators.
def backup_library(self, user_id: int, library_key_str: str) -> None:
"""
Export a library to a .zip archive and prepare it for download.
Possible Task states:
- Pending: Task is created but not started yet.
- Exporting: Task is running and the library is being exported.
- Succeeded: Task completed successfully and the exported file is available for download.
- Failed: Task failed and the export did not complete.
"""
ensure_cms("backup_library may only be executed in a CMS context")
set_code_owner_attribute_from_module(__name__)
library_key = LibraryLocatorV2.from_string(library_key_str)
try:
self.status.set_state('Exporting')
set_custom_attribute("exporting_started", str(library_key))
root_dir = Path(mkdtemp())
sanitized_lib_key = str(library_key).replace(":", "-")
sanitized_lib_key = slugify(sanitized_lib_key, allow_unicode=True)
timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
filename = f'{sanitized_lib_key}-{timestamp}.zip'
file_path = os.path.join(root_dir, filename)
create_lib_zip_file(lp_key=str(library_key), path=file_path)
set_custom_attribute("exporting_completed", str(library_key))
with open(file_path, 'rb') as zipfile:
artifact = UserTaskArtifact(status=self.status, name='Output')
artifact.file.save(name=os.path.basename(zipfile.name), content=File(zipfile))
artifact.save()
except Exception as exception: # pylint: disable=broad-except
TASK_LOGGER.exception('Error exporting library %s', library_key, exc_info=True)
if self.status.state != UserTaskStatus.FAILED:
self.status.fail({'raw_error_msg': str(exception)})

View File

@@ -32,6 +32,8 @@ URL_LIB_TEAM = URL_LIB_DETAIL + 'team/' # Get the list of users/groups authoriz
URL_LIB_TEAM_USER = URL_LIB_TEAM + 'user/{username}/' # Add/edit/remove a user's permission to use this library
URL_LIB_TEAM_GROUP = URL_LIB_TEAM + 'group/{group_name}/' # Add/edit/remove a group's permission to use this library
URL_LIB_PASTE_CLIPBOARD = URL_LIB_DETAIL + 'paste_clipboard/' # Paste user clipboard (POST) containing Xblock data
URL_LIB_BACKUP = URL_LIB_DETAIL + 'backup/' # Start a backup task for this library
URL_LIB_BACKUP_GET = URL_LIB_BACKUP + '?{query_params}' # Get status on a backup task for this library
URL_LIB_BLOCK = URL_PREFIX + 'blocks/{block_key}/' # Get data about a block, or delete it
URL_LIB_BLOCK_PUBLISH = URL_LIB_BLOCK + 'publish/' # Publish changes from a specified XBlock
URL_LIB_BLOCK_OLX = URL_LIB_BLOCK + 'olx/' # Get or set the OLX of the specified XBlock
@@ -319,6 +321,17 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
url = URL_LIB_PASTE_CLIPBOARD.format(lib_key=lib_key)
return self._api('post', url, {}, expect_response)
def _start_library_backup_task(self, lib_key, expect_response=200):
""" Start a backup task for this library """
url = URL_LIB_BACKUP.format(lib_key=lib_key)
return self._api('post', url, {}, expect_response)
def _get_library_backup_task(self, lib_key, task_id, expect_response=200):
""" Get the status of a backup task for this library """
query_params = urlencode({"task_id": task_id})
url = URL_LIB_BACKUP_GET.format(lib_key=lib_key, query_params=query_params)
return self._api('get', url, None, expect_response)
def _render_block_view(self, block_key, view_name, version=None, expect_response=200):
"""
Render an XBlock's view in the active application's runtime.

View File

@@ -4,9 +4,11 @@ Tests for Content Library internal api.
import base64
import hashlib
import uuid
from unittest import mock
from django.test import TestCase
from user_tasks.models import UserTaskStatus
from opaque_keys.edx.keys import (
CourseKey,
@@ -1309,3 +1311,95 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest):
),
},
)
class ContentLibraryExportTest(ContentLibrariesRestApiTest):
"""
Tests for Content Library API export methods.
"""
def setUp(self) -> None:
super().setUp()
# Create Content Libraries
self._create_library("test-lib-exp-1", "Test Library Export 1")
# Fetch the created ContentLibrary objects so we can access their learning_package.id
self.lib1 = ContentLibrary.objects.get(slug="test-lib-exp-1")
self.wrong_task_id = '11111111-1111-1111-1111-111111111111'
def test_get_backup_task_status_no_task(self) -> None:
status = api.get_backup_task_status(self.user.id, "")
assert status is None
def test_get_backup_task_status_wrong_task_id(self) -> None:
status = api.get_backup_task_status(self.user.id, task_id=self.wrong_task_id)
assert status is None
def test_get_backup_task_status_in_progress(self) -> None:
# Create a mock UserTaskStatus in IN_PROGRESS state
task_id = str(uuid.uuid4())
mock_task = UserTaskStatus(
task_id=task_id,
user_id=self.user.id,
name=f"Export of {self.lib1.library_key}",
state=UserTaskStatus.IN_PROGRESS
)
with mock.patch(
'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskStatus.objects.get'
) as mock_get:
mock_get.return_value = mock_task
status = api.get_backup_task_status(self.user.id, task_id=task_id)
assert status is not None
assert status['state'] == UserTaskStatus.IN_PROGRESS
assert status['url'] is None
def test_get_backup_task_status_succeeded(self) -> None:
# Create a mock UserTaskStatus in SUCCEEDED state
task_id = str(uuid.uuid4())
mock_task = UserTaskStatus(
task_id=task_id,
user_id=self.user.id,
name=f"Export of {self.lib1.library_key}",
state=UserTaskStatus.SUCCEEDED
)
# Create a mock UserTaskArtifact
mock_artifact = mock.Mock()
mock_artifact.file.storage.url.return_value = "/media/user_tasks/2025/10/01/library-libOEXCSPROB_mOw1rPL.zip"
with mock.patch(
'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskStatus.objects.get'
) as mock_get, mock.patch(
'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskArtifact.objects.get'
) as mock_artifact_get:
mock_get.return_value = mock_task
mock_artifact_get.return_value = mock_artifact
status = api.get_backup_task_status(self.user.id, task_id=task_id)
assert status is not None
assert status['state'] == UserTaskStatus.SUCCEEDED
assert status['url'] == "/media/user_tasks/2025/10/01/library-libOEXCSPROB_mOw1rPL.zip"
def test_get_backup_task_status_failed(self) -> None:
# Create a mock UserTaskStatus in FAILED state
task_id = str(uuid.uuid4())
mock_task = UserTaskStatus(
task_id=task_id,
user_id=self.user.id,
name=f"Export of {self.lib1.library_key}",
state=UserTaskStatus.FAILED
)
with mock.patch(
'openedx.core.djangoapps.content_libraries.api.libraries.UserTaskStatus.objects.get'
) as mock_get:
mock_get.return_value = mock_task
status = api.get_backup_task_status(self.user.id, task_id=task_id)
assert status is not None
assert status['state'] == UserTaskStatus.FAILED
assert status['url'] is None

View File

@@ -823,6 +823,40 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest):
"id": f"lb:CL-TEST:test_lib_paste_clipboard:problem:{pasted_usage_key.block_id}",
})
def test_start_library_backup(self):
"""
Test starting a backup operation on a content library.
"""
author = UserFactory.create(username="Author", email="author@example.com", is_staff=True)
with self.as_user(author):
lib = self._create_library(
slug="test_lib_backup",
title="Backup Test Library",
description="Testing backup for library"
)
lib_id = lib["id"]
response = self._start_library_backup_task(lib_id)
assert response["task_id"] is not None
def test_get_library_backup_status(self):
"""
Test getting the status of a backup operation on a content library.
"""
author = UserFactory.create(username="Author", email="author@example.com", is_staff=True)
with self.as_user(author):
lib = self._create_library(
slug="test_lib_backup_status",
title="Backup Status Test Library",
description="Testing backup status for library"
)
lib_id = lib["id"]
response = self._start_library_backup_task(lib_id)
task_id = response["task_id"]
# Now check the status of the backup task
status_response = self._get_library_backup_task(lib_id, task_id)
assert status_response["state"] in ["Pending", "Exporting", "Succeeded", "Failed"]
@override_settings(LIBRARY_ENABLED_BLOCKS=['problem', 'video', 'html'])
def test_library_get_enabled_blocks(self):
expected = [

View File

@@ -0,0 +1,45 @@
"""
Unit tests for content libraries Celery tasks
"""
from ..models import ContentLibrary
from .base import ContentLibrariesRestApiTest
from openedx.core.djangoapps.content_libraries.tasks import backup_library
from user_tasks.models import UserTaskArtifact
class ContentLibraryBackupTaskTest(ContentLibrariesRestApiTest):
"""
Tests for Content Library export task.
"""
def setUp(self) -> None:
super().setUp()
# Create Content Libraries
self._create_library("test-lib-task-1", "Test Library Task 1")
# Fetch the created ContentLibrary objects so we can access their learning_package.id
self.lib1 = ContentLibrary.objects.get(slug="test-lib-task-1")
self.wrong_task_id = '11111111-1111-1111-1111-111111111111'
def test_backup_task_returns_task_id(self):
result = backup_library.delay(self.user.id, str(self.lib1.library_key))
assert result.task_id is not None
def test_backup_task_success(self):
result = backup_library.delay(self.user.id, str(self.lib1.library_key))
assert result.state == 'SUCCESS'
# Ensure an artifact was created with the output file
artifact = UserTaskArtifact.objects.filter(status__task_id=result.task_id, name='Output').first()
assert artifact is not None
assert artifact.file.name.endswith('.zip')
def test_backup_task_failure(self):
result = backup_library.delay(self.user.id, self.wrong_task_id)
assert result.state == 'FAILURE'
# Ensure an error artifact was created
artifact = UserTaskArtifact.objects.filter(status__task_id=result.task_id, name='Error').first()
assert artifact is not None
assert artifact.text is not None

View File

@@ -54,6 +54,8 @@ urlpatterns = [
path('import_blocks/', include(import_blocks_router.urls)),
# Paste contents of clipboard into library
path('paste_clipboard/', libraries.LibraryPasteClipboardView.as_view()),
# Start a backup task for this library
path('backup/', libraries.LibraryBackupView.as_view()),
# Library Collections
path('', include(library_collections_router.urls)),
])),

View File

@@ -0,0 +1,244 @@
#!/usr/bin/env python
"""Tests for `user_util` package."""
import pytest
from types import GeneratorType
from openedx.core.lib import user_util
VALID_SALT_LIST_ONE_SALT = ['gsw@&2p)$^p2hdk&ou0e%c=ou80o=%!+tv7(u(ircv@+96jl6$']
VALID_SALT_LIST_THREE_SALTS = [
'^==!0%=z4s!v7!yl0#+m6-st^*946aop6$0i+hu13&h_$a$vq8',
'wdwh&#8s@(f=jnlky4up8p0#04t$jp%ip)nfp@de6rr9i)j7nf',
')h1^pu8a!rh=%$_4f7sx*5^46ln_pujw6y*s0=dl6i$_#&#io1',
]
VALID_SALT_LIST_FIVE_SALTS = [
'8rv!7iy4a7mdvs_kudis6&oycj0_b(mj0s^@*e5p)(o+m(c-cb',
'xp)43m+d_!f!-)c=ki_8oc2w9(^r^umy73%dp@z7sknn#800z$',
'some_salt_that_is_not_very_random',
'$=ldtvagk$qwc)cz%2%edaa_id45^(xg*1rs#t0inywla*)3+x',
'4eyp*!%nz&g@8(tm!236ykbg2xzwcix!=)06q&=d2rh@3n1o+8',
]
VALID_SALT_LISTS = (
VALID_SALT_LIST_ONE_SALT,
VALID_SALT_LIST_THREE_SALTS,
VALID_SALT_LIST_FIVE_SALTS,
)
INVALID_SALT_LIST = (
'gsw@&2p)$^p2hdk&ou0e%c=ou80o=%!+tv7(u(ircv@+96jl6$',
None,
[],
)
#
# Username retirement tests
#
@pytest.mark.parametrize('salt_list', VALID_SALT_LISTS)
def test_username_to_hash(salt_list):
username = 'ALearnerUserName'
retired_username = user_util.get_retired_username(username, salt_list)
assert retired_username != username
assert retired_username.startswith('_'.join(user_util.RETIRED_USERNAME_DEFAULT_FMT.split('_')[0:-1]))
# Since SHA1 is used, the hexadecimal digest length should be 40.
assert len(retired_username.split('_')[-1]) == 40
@pytest.mark.parametrize('salt_list', VALID_SALT_LISTS)
def test_username_to_hash_is_normalized(salt_list):
"""
Make sure identical usernames with different cases map to the same retired username.
"""
username_mixed = 'ALearnerUserName'
username_lower = username_mixed.lower()
retired_username_mixed = user_util.get_retired_username(username_mixed, salt_list)
retired_username_lower = user_util.get_retired_username(username_lower, salt_list)
# No matter the case of the input username, the retired username hash should be identical.
assert retired_username_mixed == retired_username_lower
def test_unicode_username_to_hash():
username = 'ÁĹéáŕńéŕŰśéŕŃáḿéẂíthŰńíćődé'
retired_username = user_util.get_retired_username(username, VALID_SALT_LIST_ONE_SALT)
assert retired_username != username
# Since SHA1 is used, the hexadecimal digest length should be 40.
assert len(retired_username.split('_')[-1]) == 40
@pytest.mark.parametrize('salt_list', (VALID_SALT_LIST_THREE_SALTS,))
def test_correct_username_hash(salt_list):
"""
Verify that get_retired_username uses the current salt and returns the expected hash.
"""
username = 'ALearnerUserName'
# Valid retired usernames for the above username when using VALID_SALT_LIST_THREE_SALTS.
valid_retired_usernames = [
# pylint: disable=protected-access
user_util.RETIRED_USERNAME_DEFAULT_FMT.format(user_util._compute_retired_hash(username.lower(), salt))
for salt in salt_list
]
retired_username = user_util.get_retired_username(username, salt_list)
assert retired_username == valid_retired_usernames[-1]
@pytest.mark.parametrize('salt_list', (VALID_SALT_LIST_FIVE_SALTS,))
def test_all_usernames_to_hash(salt_list):
username = 'ALearnerUserName'
retired_username_generator = user_util.get_all_retired_usernames(username, salt_list)
assert isinstance(retired_username_generator, GeneratorType)
assert len(list(retired_username_generator)) == len(VALID_SALT_LIST_FIVE_SALTS)
@pytest.mark.parametrize('salt_list', VALID_SALT_LISTS)
def test_username_to_hash_with_different_format(salt_list):
username = 'ALearnerUserName'
retired_username_fmt = "{}_is_now_the_retired_username"
retired_username = user_util.get_retired_username(username, salt_list, retired_username_fmt=retired_username_fmt)
assert retired_username.endswith('_'.join(retired_username_fmt.split('_')[1:]))
# Since SHA1 is used, the hexadecimal digest length should be 40.
assert len(retired_username.split('_')[0]) == 40
#
# Email address retirement tests
#
@pytest.mark.parametrize('salt_list', VALID_SALT_LISTS)
def test_email_to_hash(salt_list):
email = 'a.learner@example.com'
retired_email = user_util.get_retired_email(email, salt_list)
assert retired_email != email
assert retired_email.startswith('_'.join(user_util.RETIRED_EMAIL_DEFAULT_FMT.split('_')[0:2]))
assert retired_email.endswith(user_util.RETIRED_EMAIL_DEFAULT_FMT.split('@')[-1])
# Since SHA1 is used, the hexadecimal digest length should be 40.
assert len(retired_email.split('@')[0]) == len('retired_email_') + 40
@pytest.mark.parametrize('salt_list', VALID_SALT_LISTS)
def test_email_to_hash_is_normalized(salt_list):
"""
Make sure identical emails with different cases map to the same retired email.
"""
email_mixed = 'A.Learner@example.com'
email_lower = email_mixed.lower()
retired_email_mixed = user_util.get_retired_email(email_mixed, salt_list)
retired_email_lower = user_util.get_retired_email(email_lower, salt_list)
# No matter the case of the input email, the retired email hash should be identical.
assert retired_email_mixed == retired_email_lower
def test_unicode_email_to_hash():
email = '🅐.🅛🅔🅐🅡🅝🅔🅡r@example.com'
retired_email = user_util.get_retired_email(email, VALID_SALT_LIST_ONE_SALT)
assert retired_email != email
# Since SHA1 is used, the hexadecimal digest length should be 40.
assert len(retired_email.split('@')[0]) == len('retired_email_') + 40
@pytest.mark.parametrize('salt_list', (VALID_SALT_LIST_THREE_SALTS,))
def test_correct_email_hash(salt_list):
"""
Verify that get_retired_email uses the current salt and returns the expected hash.
"""
email = 'a.learner@example.com'
# Valid retired emails for the above email address when using VALID_SALT_LIST_THREE_SALTS.
valid_retired_emails = [
# pylint: disable=protected-access
user_util.RETIRED_EMAIL_DEFAULT_FMT.format(user_util._compute_retired_hash(email.lower(), salt))
for salt in salt_list
]
retired_email = user_util.get_retired_email(email, salt_list)
assert retired_email == valid_retired_emails[-1]
@pytest.mark.parametrize('salt_list', (VALID_SALT_LIST_FIVE_SALTS,))
def test_all_emails_to_hash(salt_list):
email = 'a.learner@example.com'
retired_email_generator = user_util.get_all_retired_emails(email, salt_list)
assert isinstance(retired_email_generator, GeneratorType)
assert len(list(retired_email_generator)) == len(VALID_SALT_LIST_FIVE_SALTS)
@pytest.mark.parametrize('salt_list', VALID_SALT_LISTS)
def test_email_to_hash_with_different_format(salt_list):
email = 'a.learner@example.com'
retired_email_fmt = "{}_is_now_the_retired_email@devnull.example.com"
retired_email = user_util.get_retired_email(email, salt_list, retired_email_fmt=retired_email_fmt)
assert retired_email.endswith('_'.join(retired_email_fmt.split('_')[1:]))
# Since SHA1 is used, the hexadecimal digest length should be 40.
assert len(retired_email.split('_')[0]) == 40
#
# Bad salt tests.
#
@pytest.mark.parametrize('salt', INVALID_SALT_LIST)
def test_username_to_hash_bad_salt(salt):
"""
Salts that are *not* lists/tuples should fail.
"""
with pytest.raises((ValueError, IndexError)):
_ = user_util.get_retired_username('AnotherLearnerUserName', salt)
#
# External user retirement tests
#
@pytest.mark.parametrize('salt_list', VALID_SALT_LISTS)
def test_external_key_to_hash(salt_list):
external_key = '343ni3hr3ifh3fgghg'
retired_external_key = user_util.get_retired_external_key(external_key, salt_list)
assert retired_external_key != external_key
assert retired_external_key.startswith(
'_'.join(user_util.RETIRED_EXTERNAL_KEY_DEFAULT_FMT.split('_')[0:3])
)
# Since SHA1 is used, the hexadecimal digest length should be 40.
assert len(retired_external_key) == len('retired_external_key_') + 40
def test_unicode_external_key_to_hash():
unicode_external_key = '🅐.🅛🅔🅐🅡🅝🅔🅡'
retired_external_key = user_util.get_retired_external_key(unicode_external_key, VALID_SALT_LIST_ONE_SALT)
assert retired_external_key != unicode_external_key
# Since SHA1 is used, the hexadecimal digest length should be 40.
assert len(retired_external_key) == len('retired_external_key_') + 40
@pytest.mark.parametrize('salt_list', (VALID_SALT_LIST_THREE_SALTS,))
def test_correct_external_key_hash(salt_list):
"""
Verify that get_retired_external_key uses the current salt and returns the expected hash.
"""
external_key = 'S34839GEF3'
valid_retired_external_keys = [
# pylint: disable=protected-access
user_util.RETIRED_EXTERNAL_KEY_DEFAULT_FMT.format(
user_util._compute_retired_hash(external_key.lower(), salt)
)
for salt in salt_list
]
retired_email = user_util.get_retired_external_key(external_key, salt_list)
assert retired_email == valid_retired_external_keys[-1]
@pytest.mark.parametrize('salt_list', (VALID_SALT_LIST_FIVE_SALTS,))
def test_all_external_keys_to_hash(salt_list):
external_key = 'S34839GEF3'
retired_external_key_generator = user_util.get_all_retired_external_keys(external_key, salt_list)
assert isinstance(retired_external_key_generator, GeneratorType)
assert len(list(retired_external_key_generator)) == len(VALID_SALT_LIST_FIVE_SALTS)
@pytest.mark.parametrize('salt_list', VALID_SALT_LISTS)
def test_external_key_to_hash_with_different_format(salt_list):
external_key = 'S34839GEF3'
retired_external_key_fmt = "{}_is_now_the_retired_external_key"
retired_external_key = user_util.get_retired_external_key(
external_key,
salt_list,
retired_external_key_fmt=retired_external_key_fmt
)
assert retired_external_key.endswith('_is_now_the_retired_external_key')
# Since SHA1 is used, the hexadecimal digest length should be 40.
assert len(retired_external_key.split('_')[0]) == 40

View File

@@ -0,0 +1,157 @@
"""Main module."""
import hashlib
RETIRED_USERNAME_DEFAULT_FMT = 'retired_username_{}'
RETIRED_EMAIL_DEFAULT_FMT = 'retired_email_{}@retired.edx.org'
RETIRED_EXTERNAL_KEY_DEFAULT_FMT = 'retired_external_key_{}'
SALT_LIST_EXCEPTION = ValueError("Salt must be a list -or- tuple of all historical salts.")
def _compute_retired_hash(value_to_retire, salt):
"""
Returns a retired value given a value to retire and a hash.
Arguments:
value_to_retire (str): Value to be retired.
salt (str): Salt string used to modify the retired value before hashing.
"""
return hashlib.sha1(
salt.encode() + value_to_retire.encode('utf-8')
).hexdigest()
def get_all_retired_usernames(username, salt_list, retired_username_fmt=RETIRED_USERNAME_DEFAULT_FMT):
"""
Returns a generator of possible retired usernames based on the original
lowercased username and all the historical salts, from oldest to current.
The current salt is assumed to be the last salt in the list.
Raises :class:`~ValueError` if the salt isn't a list of salts.
Arguments:
username (str): The name of the user to be retired.
salt_list (list/tuple): List of all historical salts.
Yields:
Returns a generator of possible retired usernames based on the original username
and all the historical salts, including the current salt, from oldest to current.
"""
if not isinstance(salt_list, (list, tuple)):
raise SALT_LIST_EXCEPTION
for salt in salt_list:
yield retired_username_fmt.format(_compute_retired_hash(username.lower(), salt))
def get_all_retired_emails(email, salt_list, retired_email_fmt=RETIRED_EMAIL_DEFAULT_FMT):
"""
Returns a generator of possible retired email addresses based on the
original lowercased email and all the historical salts, from oldest to
current. The current salt is assumed to be the last salt in the list.
Raises :class:`~ValueError` if the salt isn't a list of salts.
Arguments:
email (str): Email address of the user to be retired.
salt_list (list/tuple): List of all historical salts.
Yields:
Returns a generator of possible retired email addresses based on the original email
and all the historical salts, including the current salt, from oldest to current.
"""
if not isinstance(salt_list, (list, tuple)):
raise SALT_LIST_EXCEPTION
for salt in salt_list:
yield retired_email_fmt.format(_compute_retired_hash(email.lower(), salt))
def get_all_retired_external_keys(external_key, salt_list, retired_external_key_fmt=RETIRED_EXTERNAL_KEY_DEFAULT_FMT):
"""
Returns a generator of possible retired external user key based on the
original external user key and all the historical salts, from oldest to
current. The current salt is assumed to be the last salt in the list.
Raises :class:`~ValueError` if the salt isn't a list of salts.
Arguments:
external_key (str): External user key of the user to be retired.
salt_list (list/tuple): List of all historical salts.
Yields:
Returns a generator of possible retired external user keys based on the original external key
and all the historical salts, including the current salt, from oldest to current.
"""
if not isinstance(salt_list, (list, tuple)):
raise SALT_LIST_EXCEPTION
for salt in salt_list:
yield retired_external_key_fmt.format(_compute_retired_hash(external_key.lower(), salt))
def get_retired_username(username, salt_list, retired_username_fmt=RETIRED_USERNAME_DEFAULT_FMT):
"""
Returns a retired username based on the original lowercased username and
all the historical salts, from oldest to current. The current salt is
assumed to be the last salt in the list.
Raises :class:`~ValueError` if the salt isn't a list of salts.
Arguments:
username (str): The name of the user to be retired.
salt_list (list/tuple): List of all historical salts.
Yields:
Returns a retired username based on the original username
and all the historical salts, including the current salt.
"""
if not isinstance(salt_list, (list, tuple)):
raise SALT_LIST_EXCEPTION
return retired_username_fmt.format(_compute_retired_hash(username.lower(), salt_list[-1]))
def get_retired_email(email, salt_list, retired_email_fmt=RETIRED_EMAIL_DEFAULT_FMT):
"""
Returns a retired email address based on the original lowercased email
address and the current salt. The current salt is assumed to be the last
salt in the list.
Raises :class:`~ValueError` if salt_list isn't a list of salts.
Arguments:
email (str): Email address of the user to be retired.
salt_list (list/tuple): List of all historical salts.
Yields:
Returns a retired email address based on the original email
and the current salt
"""
if not isinstance(salt_list, (list, tuple)):
raise SALT_LIST_EXCEPTION
return retired_email_fmt.format(_compute_retired_hash(email.lower(), salt_list[-1]))
def get_retired_external_key(external_key, salt_list, retired_external_key_fmt=RETIRED_EXTERNAL_KEY_DEFAULT_FMT):
"""
Returns a retired external user key based on the original external key and the current salt.
The current salt is assumed to be the last salt in the list.
Raises :class:`~ValueError` if salt_list isn't a list of salts.
Arguments:
external_key (str): External user key of the user to be retired.
salt_list (list/tuple): List of all historical salts.
Yields:
Returns a retired external user key based on the original external_user_key
and the current salt
"""
if not isinstance(salt_list, (list, tuple)):
raise SALT_LIST_EXCEPTION
return retired_external_key_fmt.format(
_compute_retired_hash(external_key.lower(), salt_list[-1])
)

View File

@@ -2245,6 +2245,10 @@ AI_TRANSLATIONS_API_URL = 'http://localhost:18760/api/v1'
def should_send_learning_badge_events(settings):
return settings.BADGES_ENABLED
############################## ALLOWED_HOSTS ###############################
ALLOWED_HOSTS = ['*']
############################## Miscellaneous ###############################
COURSE_MODE_DEFAULTS = {

View File

@@ -22,7 +22,3 @@
# elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html
# See https://github.com/openedx/edx-platform/issues/35126 for more info
elasticsearch<7.14.0
# Cause: https://github.com/openedx/edx-lint/issues/458
# This can be unpinned once https://github.com/openedx/edx-lint/issues/459 has been resolved.

View File

@@ -121,3 +121,14 @@ xmlsec==1.3.14
# https://github.com/django-commons/django-debug-toolbar/issues/2172
# Pin this back to the previous version until that bug is fixed.
django-debug-toolbar<6.0.0
# Date 2025-10-07
# Cryptography 46.0.0 conflicts with system dependencies needed for snowflake-connector-python
# snowflake-connector-python comes as a dependency of edx-enterprise so it can not be directly pinned here.
# See issue https://github.com/openedx/edx-platform/issues/37417 for details on this.
# This can be unpinned once snowflake-connector-python==4.0.0 is available (contains the fix).
# pact-python==3.0.0 also removes cffi dependency and is causing the upgrade build to fail
# This should also be removed together with cryptography constraint.
# Issue: https://github.com/openedx/edx-platform/issues/37435
cryptography<46.0.0
pact-python<3.0.0

View File

@@ -8,17 +8,19 @@ cffi==2.0.0
# via cryptography
chem==2.0.0
# via -r requirements/edx-sandbox/base.in
click==8.2.1
click==8.3.0
# via nltk
codejail-includes==2.0.0
# via -r requirements/edx-sandbox/base.in
contourpy==1.3.3
# via matplotlib
cryptography==45.0.7
# via -r requirements/edx-sandbox/base.in
# via
# -c requirements/constraints.txt
# -r requirements/edx-sandbox/base.in
cycler==0.12.1
# via matplotlib
fonttools==4.59.2
fonttools==4.60.1
# via matplotlib
joblib==1.5.2
# via nltk
@@ -30,9 +32,9 @@ lxml[html-clean]==5.3.2
# -r requirements/edx-sandbox/base.in
# lxml-html-clean
# openedx-calc
lxml-html-clean==0.4.2
lxml-html-clean==0.4.3
# via lxml
markupsafe==3.0.2
markupsafe==3.0.3
# via
# chem
# openedx-calc
@@ -42,7 +44,7 @@ mpmath==1.3.0
# via sympy
networkx==3.5
# via -r requirements/edx-sandbox/base.in
nltk==3.9.1
nltk==3.9.2
# via
# -r requirements/edx-sandbox/base.in
# chem
@@ -62,7 +64,7 @@ pillow==11.3.0
# via matplotlib
pycparser==2.23
# via cffi
pyparsing==3.2.4
pyparsing==3.2.5
# via
# -r requirements/edx-sandbox/base.in
# chem
@@ -72,7 +74,7 @@ python-dateutil==2.9.0.post0
# via matplotlib
random2==1.0.2
# via -r requirements/edx-sandbox/base.in
regex==2025.9.1
regex==2025.9.18
# via nltk
scipy==1.16.2
# via

View File

@@ -4,7 +4,7 @@
#
# make upgrade
#
click==8.2.1
click==8.3.0
# via -r requirements/edx/assets.in
libsass==0.10.0
# via

View File

@@ -8,7 +8,7 @@ acid-xblock==0.4.1
# via -r requirements/edx/kernel.in
aiohappyeyeballs==2.6.1
# via aiohttp
aiohttp==3.12.15
aiohttp==3.13.0
# via
# geoip2
# openai
@@ -22,18 +22,18 @@ aniso8601==10.0.1
# via edx-tincan-py35
annotated-types==0.7.0
# via pydantic
anyio==4.10.0
anyio==4.11.0
# via httpx
appdirs==1.4.4
# via fs
asgiref==3.9.1
asgiref==3.10.0
# via
# django
# django-cors-headers
# django-countries
asn1crypto==1.5.1
# via snowflake-connector-python
attrs==25.3.0
attrs==25.4.0
# via
# -r requirements/edx/kernel.in
# aiohttp
@@ -50,13 +50,13 @@ babel==2.17.0
# enmerkar-underscore
backoff==1.10.0
# via analytics-python
bcrypt==4.3.0
bcrypt==5.0.0
# via paramiko
beautifulsoup4==4.13.5
beautifulsoup4==4.14.2
# via
# openedx-forum
# pynliner
billiard==4.2.1
billiard==4.2.2
# via celery
bleach[css]==6.2.0
# via
@@ -68,14 +68,14 @@ bleach[css]==6.2.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/kernel.in
boto3==1.40.31
boto3==1.40.46
# via
# -r requirements/edx/kernel.in
# django-ses
# fs-s3fs
# ora2
# snowflake-connector-python
botocore==1.40.31
botocore==1.40.46
# via
# -r requirements/edx/kernel.in
# boto3
@@ -85,7 +85,7 @@ bridgekeeper==0.9
# via -r requirements/edx/kernel.in
cachecontrol==0.14.3
# via firebase-admin
cachetools==5.5.2
cachetools==6.2.0
# via
# edxval
# google-auth
@@ -102,7 +102,7 @@ celery==5.5.3
# enterprise-integrated-channels
# event-tracking
# openedx-learning
certifi==2025.8.3
certifi==2025.10.5
# via
# elasticsearch
# httpcore
@@ -122,7 +122,7 @@ charset-normalizer==3.4.3
# snowflake-connector-python
chem==2.0.0
# via -r requirements/edx/kernel.in
click==8.2.1
click==8.3.0
# via
# celery
# click-didyoumean
@@ -131,7 +131,6 @@ click==8.2.1
# code-annotations
# edx-django-utils
# nltk
# user-util
click-didyoumean==0.3.1
# via celery
click-plugins==1.1.1.2
@@ -148,6 +147,7 @@ crowdsourcehinter-xblock==0.8
# via -r requirements/edx/bundled.in
cryptography==45.0.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/kernel.in
# django-fernet-fields-v2
# edx-enterprise
@@ -257,7 +257,7 @@ django-config-models==2.9.0
# edx-name-affirmation
# enterprise-integrated-channels
# lti-consumer-xblock
django-cors-headers==4.8.0
django-cors-headers==4.9.0
# via -r requirements/edx/kernel.in
django-countries==7.6.1
# via
@@ -315,7 +315,7 @@ django-mptt==0.18.0
# openedx-django-wiki
django-multi-email-field==0.8.0
# via edx-enterprise
django-mysql==4.18.0
django-mysql==4.19.0
# via -r requirements/edx/kernel.in
django-oauth-toolkit==1.7.1
# via
@@ -403,7 +403,7 @@ drf-jwt==1.19.2
# via edx-drf-extensions
drf-spectacular==0.28.0
# via -r requirements/edx/kernel.in
drf-yasg==1.21.10
drf-yasg==1.21.11
# via
# django-user-tasks
# edx-api-doc-tools
@@ -413,7 +413,7 @@ edx-api-doc-tools==2.1.0
# via
# -r requirements/edx/kernel.in
# edx-name-affirmation
edx-auth-backends==4.6.0
edx-auth-backends==4.6.1
# via -r requirements/edx/kernel.in
edx-bulk-grades==1.2.0
# via
@@ -440,7 +440,7 @@ edx-django-release-util==1.5.0
# edxval
edx-django-sites-extensions==5.1.0
# via -r requirements/edx/kernel.in
edx-django-utils==8.0.0
edx-django-utils==8.0.1
# via
# -r requirements/edx/kernel.in
# django-config-models
@@ -527,7 +527,7 @@ edx-search==4.3.0
# openedx-forum
edx-sga==0.26.0
# via -r requirements/edx/bundled.in
edx-submissions==3.11.1
edx-submissions==3.12.0
# via
# -r requirements/edx/kernel.in
# ora2
@@ -564,7 +564,7 @@ enmerkar==0.7.1
# via enmerkar-underscore
enmerkar-underscore==2.4.0
# via -r requirements/edx/kernel.in
enterprise-integrated-channels==0.1.16
enterprise-integrated-channels==0.1.18
# via -r requirements/edx/bundled.in
event-tracking==3.3.0
# via
@@ -578,7 +578,7 @@ filelock==3.19.1
# via snowflake-connector-python
firebase-admin==7.1.0
# via edx-ace
frozenlist==1.7.0
frozenlist==1.8.0
# via
# aiohttp
# aiosignal
@@ -596,13 +596,13 @@ geoip2==5.1.0
# via -r requirements/edx/kernel.in
glob2==0.7
# via -r requirements/edx/kernel.in
google-api-core[grpc]==2.25.1
google-api-core[grpc]==2.25.2
# via
# firebase-admin
# google-cloud-core
# google-cloud-firestore
# google-cloud-storage
google-auth==2.40.3
google-auth==2.41.1
# via
# google-api-core
# google-cloud-core
@@ -626,11 +626,11 @@ googleapis-common-protos==1.70.0
# via
# google-api-core
# grpcio-status
grpcio==1.74.0
grpcio==1.75.1
# via
# google-api-core
# grpcio-status
grpcio-status==1.74.0
grpcio-status==1.75.1
# via google-api-core
gunicorn==23.0.0
# via -r requirements/edx/kernel.in
@@ -732,7 +732,7 @@ lxml[html-clean]==5.3.2
# python3-saml
# xblock
# xmlsec
lxml-html-clean==0.4.2
lxml-html-clean==0.4.3
# via lxml
mailsnake==1.6.4
# via -r requirements/edx/bundled.in
@@ -749,7 +749,7 @@ markdown==3.9
# openedx-django-wiki
# staff-graded-xblock
# xblock-poll
markupsafe==3.0.2
markupsafe==3.0.3
# via
# chem
# jinja2
@@ -772,7 +772,7 @@ mpmath==1.3.0
# via sympy
msgpack==1.1.1
# via cachecontrol
multidict==6.6.4
multidict==6.7.0
# via
# aiohttp
# yarl
@@ -784,7 +784,7 @@ nh3==0.3.0
# via
# -r requirements/edx/kernel.in
# xblocks-contrib
nltk==3.9.1
nltk==3.9.2
# via chem
nodeenv==1.9.1
# via -r requirements/edx/kernel.in
@@ -884,7 +884,7 @@ polib==1.2.0
# via edx-i18n-tools
prompt-toolkit==3.0.52
# via click-repl
propcache==0.3.2
propcache==0.4.0
# via
# aiohttp
# yarl
@@ -899,7 +899,7 @@ protobuf==6.32.1
# googleapis-common-protos
# grpcio-status
# proto-plus
psutil==7.0.0
psutil==7.1.0
# via
# -r requirements/edx/kernel.in
# edx-django-utils
@@ -919,7 +919,7 @@ pycryptodomex==3.23.0
# -r requirements/edx/kernel.in
# edx-proctoring
# lti-consumer-xblock
pydantic==2.11.9
pydantic==2.11.10
# via camel-converter
pydantic-core==2.33.2
# via pydantic
@@ -956,9 +956,9 @@ pynacl==1.6.0
# paramiko
pynliner==0.8.0
# via -r requirements/edx/kernel.in
pyopenssl==25.2.0
pyopenssl==25.3.0
# via snowflake-connector-python
pyparsing==3.2.4
pyparsing==3.2.5
# via
# chem
# openedx-calc
@@ -1010,7 +1010,7 @@ pytz==2025.2
# xblock
pyuca==1.2
# via -r requirements/edx/kernel.in
pyyaml==6.0.2
pyyaml==6.0.3
# via
# -r requirements/edx/kernel.in
# code-annotations
@@ -1032,7 +1032,7 @@ referencing==0.36.2
# via
# jsonschema
# jsonschema-specifications
regex==2025.9.1
regex==2025.9.18
# via nltk
requests==2.32.5
# via
@@ -1084,9 +1084,9 @@ scipy==1.16.2
# via chem
semantic-version==2.10.0
# via edx-drf-extensions
shapely==2.1.1
shapely==2.1.2
# via -r requirements/edx/kernel.in
simplejson==3.20.1
simplejson==3.20.2
# via
# -r requirements/edx/kernel.in
# sailthru-client
@@ -1118,7 +1118,7 @@ slumber==0.7.1
# enterprise-integrated-channels
sniffio==1.3.1
# via anyio
snowflake-connector-python==3.17.3
snowflake-connector-python==3.18.0
# via edx-enterprise
social-auth-app-django==5.4.1
# via
@@ -1177,6 +1177,7 @@ typing-extensions==4.15.0
# beautifulsoup4
# django-countries
# edx-opaque-keys
# grpcio
# jwcrypto
# pydantic
# pydantic-core
@@ -1185,7 +1186,7 @@ typing-extensions==4.15.0
# referencing
# snowflake-connector-python
# typing-inspection
typing-inspection==0.4.1
typing-inspection==0.4.2
# via pydantic
tzdata==2025.2
# via
@@ -1207,8 +1208,6 @@ urllib3==2.5.0
# botocore
# elasticsearch
# requests
user-util==2.0.0
# via -r requirements/edx/kernel.in
vine==5.1.0
# via
# amqp
@@ -1218,7 +1217,7 @@ voluptuous==0.15.2
# via ora2
walrus==0.9.5
# via edx-event-bus-redis
wcwidth==0.2.13
wcwidth==0.2.14
# via prompt-toolkit
web-fragments==3.1.0
# via
@@ -1275,7 +1274,7 @@ xmlsec==1.3.14
# python3-saml
xss-utils==0.8.0
# via -r requirements/edx/kernel.in
yarl==1.20.1
yarl==1.22.0
# via aiohttp
zipp==3.23.0
# via importlib-metadata

View File

@@ -6,13 +6,13 @@
#
chardet==5.2.0
# via diff-cover
coverage==7.10.6
coverage==7.10.7
# via -r requirements/edx/coverage.in
diff-cover==9.6.0
diff-cover==9.7.1
# via -r requirements/edx/coverage.in
jinja2==3.1.6
# via diff-cover
markupsafe==3.0.2
markupsafe==3.0.3
# via jinja2
pluggy==1.6.0
# via diff-cover

View File

@@ -17,7 +17,7 @@ aiohappyeyeballs==2.6.1
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# aiohttp
aiohttp==3.12.15
aiohttp==3.13.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -51,7 +51,7 @@ annotated-types==0.7.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# pydantic
anyio==4.10.0
anyio==4.11.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -62,7 +62,7 @@ appdirs==1.4.4
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# fs
asgiref==3.9.1
asgiref==3.10.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -81,7 +81,7 @@ astroid==3.3.11
# pylint
# pylint-celery
# sphinx-autoapi
attrs==25.3.0
attrs==25.4.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -105,19 +105,19 @@ backoff==1.10.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# analytics-python
bcrypt==4.3.0
bcrypt==5.0.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# paramiko
beautifulsoup4==4.13.5
beautifulsoup4==4.14.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# openedx-forum
# pydata-sphinx-theme
# pynliner
billiard==4.2.1
billiard==4.2.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -136,7 +136,7 @@ boto==2.49.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
boto3==1.40.31
boto3==1.40.46
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -144,7 +144,7 @@ boto3==1.40.31
# fs-s3fs
# ora2
# snowflake-connector-python
botocore==1.40.31
botocore==1.40.46
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -164,7 +164,7 @@ cachecontrol==0.14.3
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# firebase-admin
cachetools==5.5.2
cachetools==6.2.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -188,7 +188,7 @@ celery==5.5.3
# enterprise-integrated-channels
# event-tracking
# openedx-learning
certifi==2025.8.3
certifi==2025.10.5
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -222,7 +222,7 @@ chem==2.0.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
click==8.2.1
click==8.3.0
# via
# -r requirements/edx/assets.txt
# -r requirements/edx/development.in
@@ -241,7 +241,6 @@ click==8.2.1
# nltk
# pact-python
# pip-tools
# user-util
# uvicorn
click-didyoumean==0.3.1
# via
@@ -277,7 +276,7 @@ colorama==0.4.6
# via
# -r requirements/edx/testing.txt
# tox
coverage[toml]==7.10.6
coverage[toml]==7.10.7
# via
# -r requirements/edx/testing.txt
# pytest-cov
@@ -287,6 +286,7 @@ crowdsourcehinter-xblock==0.8
# -r requirements/edx/testing.txt
cryptography==45.0.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# django-fernet-fields-v2
@@ -321,7 +321,7 @@ defusedxml==0.7.1
# ora2
# python3-openid
# social-auth-core
diff-cover==9.6.0
diff-cover==9.7.1
# via -r requirements/edx/testing.txt
dill==0.4.0
# via
@@ -439,7 +439,7 @@ django-config-models==2.9.0
# edx-name-affirmation
# enterprise-integrated-channels
# lti-consumer-xblock
django-cors-headers==4.8.0
django-cors-headers==4.9.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -519,7 +519,7 @@ django-multi-email-field==0.8.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-enterprise
django-mysql==4.18.0
django-mysql==4.19.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -581,12 +581,12 @@ django-storages==1.14.6
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edxval
django-stubs[compatible-mypy]==5.2.5
django-stubs[compatible-mypy]==5.2.6
# via
# -c requirements/constraints.txt
# -r requirements/edx/development.in
# djangorestframework-stubs
django-stubs-ext==5.2.5
django-stubs-ext==5.2.6
# via django-stubs
django-user-tasks==3.4.3
# via
@@ -627,7 +627,7 @@ djangorestframework==3.16.1
# openedx-learning
# ora2
# super-csv
djangorestframework-stubs==3.16.2
djangorestframework-stubs==3.16.4
# via -r requirements/edx/development.in
djangorestframework-xml==2.0.0
# via
@@ -658,7 +658,7 @@ drf-spectacular==0.28.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
drf-yasg==1.21.10
drf-yasg==1.21.11
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -673,7 +673,7 @@ edx-api-doc-tools==2.1.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-name-affirmation
edx-auth-backends==4.6.0
edx-auth-backends==4.6.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -712,7 +712,7 @@ edx-django-sites-extensions==5.1.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
edx-django-utils==8.0.0
edx-django-utils==8.0.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -824,7 +824,7 @@ edx-sga==0.26.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
edx-submissions==3.11.1
edx-submissions==3.12.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -875,7 +875,7 @@ enmerkar-underscore==2.4.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
enterprise-integrated-channels==0.1.16
enterprise-integrated-channels==0.1.18
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -896,7 +896,7 @@ faker==37.8.0
# via
# -r requirements/edx/testing.txt
# factory-boy
fastapi==0.116.1
fastapi==0.118.0
# via
# -r requirements/edx/testing.txt
# pact-python
@@ -919,7 +919,7 @@ firebase-admin==7.1.0
# edx-ace
freezegun==1.5.5
# via -r requirements/edx/testing.txt
frozenlist==1.7.0
frozenlist==1.8.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -951,7 +951,7 @@ glob2==0.7
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
google-api-core[grpc]==2.25.1
google-api-core[grpc]==2.25.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -959,7 +959,7 @@ google-api-core[grpc]==2.25.1
# google-cloud-core
# google-cloud-firestore
# google-cloud-storage
google-auth==2.40.3
google-auth==2.41.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1004,13 +1004,13 @@ grimp==3.11
# via
# -r requirements/edx/testing.txt
# import-linter
grpcio==1.74.0
grpcio==1.75.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# google-api-core
# grpcio-status
grpcio-status==1.74.0
grpcio-status==1.75.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1109,7 +1109,7 @@ isodate==0.7.2
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# python3-saml
isort==6.0.1
isort==6.1.0
# via
# -r requirements/edx/testing.txt
# pylint
@@ -1212,7 +1212,7 @@ lxml[html-clean]==5.3.2
# python3-saml
# xblock
# xmlsec
lxml-html-clean==0.4.2
lxml-html-clean==0.4.3
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1236,7 +1236,7 @@ markdown==3.9
# openedx-django-wiki
# staff-graded-xblock
# xblock-poll
markupsafe==3.0.2
markupsafe==3.0.3
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1289,13 +1289,13 @@ msgpack==1.1.1
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# cachecontrol
multidict==6.6.4
multidict==6.7.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# aiohttp
# yarl
mypy==1.18.1
mypy==1.18.2
# via
# -r requirements/edx/development.in
# django-stubs
@@ -1311,7 +1311,7 @@ nh3==0.3.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# xblocks-contrib
nltk==3.9.1
nltk==3.9.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1423,7 +1423,9 @@ packaging==25.0
# sphinx
# tox
pact-python==2.3.3
# via -r requirements/edx/testing.txt
# via
# -c requirements/constraints.txt
# -r requirements/edx/testing.txt
paramiko==4.0.0
# via
# -r requirements/edx/doc.txt
@@ -1465,7 +1467,7 @@ pillow==11.3.0
# edx-enterprise
# edx-organizations
# edxval
pip-tools==7.5.0
pip-tools==7.5.1
# via -r requirements/pip-tools.txt
platformdirs==4.4.0
# via
@@ -1492,7 +1494,7 @@ prompt-toolkit==3.0.52
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# click-repl
propcache==0.3.2
propcache==0.4.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1513,7 +1515,7 @@ protobuf==6.32.1
# googleapis-common-protos
# grpcio-status
# proto-plus
psutil==7.0.0
psutil==7.1.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1553,7 +1555,7 @@ pycryptodomex==3.23.0
# -r requirements/edx/testing.txt
# edx-proctoring
# lti-consumer-xblock
pydantic==2.11.9
pydantic==2.11.10
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1596,7 +1598,7 @@ pylatexenc==2.10
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# olxcleaner
pylint==3.3.8
pylint==3.3.9
# via
# -r requirements/edx/testing.txt
# edx-lint
@@ -1646,12 +1648,12 @@ pynliner==0.8.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
pyopenssl==25.2.0
pyopenssl==25.3.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# snowflake-connector-python
pyparsing==3.2.4
pyparsing==3.2.5
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1766,7 +1768,7 @@ pyuca==1.2
# -r requirements/edx/testing.txt
pywatchman==3.0.0
# via -r requirements/edx/development.in
pyyaml==6.0.2
pyyaml==6.0.3
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1798,7 +1800,7 @@ referencing==0.36.2
# -r requirements/edx/testing.txt
# jsonschema
# jsonschema-specifications
regex==2025.9.1
regex==2025.9.18
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1881,11 +1883,11 @@ semantic-version==2.10.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-drf-extensions
shapely==2.1.1
shapely==2.1.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
simplejson==3.20.1
simplejson==3.20.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1938,7 +1940,7 @@ snowballstemmer==3.0.1
# via
# -r requirements/edx/doc.txt
# sphinx
snowflake-connector-python==3.17.3
snowflake-connector-python==3.18.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1982,7 +1984,7 @@ sphinx==8.2.3
# sphinxcontrib-httpdomain
# sphinxcontrib-openapi
# sphinxext-rediraffe
sphinx-autoapi==3.6.0
sphinx-autoapi==3.6.1
# via -r requirements/edx/doc.txt
sphinx-book-theme==1.1.4
# via -r requirements/edx/doc.txt
@@ -2024,7 +2026,7 @@ sphinxcontrib-serializinghtml==2.0.0
# via
# -r requirements/edx/doc.txt
# sphinx
sphinxext-rediraffe==0.2.7
sphinxext-rediraffe==0.3.0
# via -r requirements/edx/doc.txt
sqlparse==0.5.3
# via
@@ -2036,7 +2038,7 @@ staff-graded-xblock==3.1.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
starlette==0.47.3
starlette==0.48.0
# via
# -r requirements/edx/testing.txt
# fastapi
@@ -2081,7 +2083,7 @@ tomlkit==0.13.3
# openedx-learning
# pylint
# snowflake-connector-python
tox==4.27.0
tox==4.30.3
# via -r requirements/edx/testing.txt
tqdm==4.67.1
# via
@@ -2109,6 +2111,7 @@ typing-extensions==4.15.0
# edx-opaque-keys
# fastapi
# grimp
# grpcio
# import-linter
# jwcrypto
# mypy
@@ -2121,7 +2124,7 @@ typing-extensions==4.15.0
# snowflake-connector-python
# starlette
# typing-inspection
typing-inspection==0.4.1
typing-inspection==0.4.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -2159,11 +2162,7 @@ urllib3==2.5.0
# elasticsearch
# requests
# types-requests
user-util==2.0.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
uvicorn==0.35.0
uvicorn==0.37.0
# via
# -r requirements/edx/testing.txt
# pact-python
@@ -2192,7 +2191,7 @@ walrus==0.9.5
# edx-event-bus-redis
watchdog==6.0.0
# via -r requirements/edx/development.in
wcwidth==0.2.13
wcwidth==0.2.14
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -2278,7 +2277,7 @@ xss-utils==0.8.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
yarl==1.20.1
yarl==1.22.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt

View File

@@ -12,7 +12,7 @@ aiohappyeyeballs==2.6.1
# via
# -r requirements/edx/base.txt
# aiohttp
aiohttp==3.12.15
aiohttp==3.13.0
# via
# -r requirements/edx/base.txt
# geoip2
@@ -37,7 +37,7 @@ annotated-types==0.7.0
# via
# -r requirements/edx/base.txt
# pydantic
anyio==4.10.0
anyio==4.11.0
# via
# -r requirements/edx/base.txt
# httpx
@@ -45,7 +45,7 @@ appdirs==1.4.4
# via
# -r requirements/edx/base.txt
# fs
asgiref==3.9.1
asgiref==3.10.0
# via
# -r requirements/edx/base.txt
# django
@@ -57,7 +57,7 @@ asn1crypto==1.5.1
# snowflake-connector-python
astroid==3.3.11
# via sphinx-autoapi
attrs==25.3.0
attrs==25.4.0
# via
# -r requirements/edx/base.txt
# aiohttp
@@ -78,17 +78,17 @@ backoff==1.10.0
# via
# -r requirements/edx/base.txt
# analytics-python
bcrypt==4.3.0
bcrypt==5.0.0
# via
# -r requirements/edx/base.txt
# paramiko
beautifulsoup4==4.13.5
beautifulsoup4==4.14.2
# via
# -r requirements/edx/base.txt
# openedx-forum
# pydata-sphinx-theme
# pynliner
billiard==4.2.1
billiard==4.2.2
# via
# -r requirements/edx/base.txt
# celery
@@ -103,14 +103,14 @@ bleach[css]==6.2.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/base.txt
boto3==1.40.31
boto3==1.40.46
# via
# -r requirements/edx/base.txt
# django-ses
# fs-s3fs
# ora2
# snowflake-connector-python
botocore==1.40.31
botocore==1.40.46
# via
# -r requirements/edx/base.txt
# boto3
@@ -122,7 +122,7 @@ cachecontrol==0.14.3
# via
# -r requirements/edx/base.txt
# firebase-admin
cachetools==5.5.2
cachetools==6.2.0
# via
# -r requirements/edx/base.txt
# edxval
@@ -142,7 +142,7 @@ celery==5.5.3
# enterprise-integrated-channels
# event-tracking
# openedx-learning
certifi==2025.8.3
certifi==2025.10.5
# via
# -r requirements/edx/base.txt
# elasticsearch
@@ -167,7 +167,7 @@ charset-normalizer==3.4.3
# snowflake-connector-python
chem==2.0.0
# via -r requirements/edx/base.txt
click==8.2.1
click==8.3.0
# via
# -r requirements/edx/base.txt
# celery
@@ -177,7 +177,6 @@ click==8.2.1
# code-annotations
# edx-django-utils
# nltk
# user-util
click-didyoumean==0.3.1
# via
# -r requirements/edx/base.txt
@@ -202,6 +201,7 @@ crowdsourcehinter-xblock==0.8
# via -r requirements/edx/base.txt
cryptography==45.0.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/base.txt
# django-fernet-fields-v2
# edx-enterprise
@@ -321,7 +321,7 @@ django-config-models==2.9.0
# edx-name-affirmation
# enterprise-integrated-channels
# lti-consumer-xblock
django-cors-headers==4.8.0
django-cors-headers==4.9.0
# via -r requirements/edx/base.txt
django-countries==7.6.1
# via
@@ -384,7 +384,7 @@ django-multi-email-field==0.8.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
django-mysql==4.18.0
django-mysql==4.19.0
# via -r requirements/edx/base.txt
django-oauth-toolkit==1.7.1
# via
@@ -486,7 +486,7 @@ drf-jwt==1.19.2
# edx-drf-extensions
drf-spectacular==0.28.0
# via -r requirements/edx/base.txt
drf-yasg==1.21.10
drf-yasg==1.21.11
# via
# -r requirements/edx/base.txt
# django-user-tasks
@@ -497,7 +497,7 @@ edx-api-doc-tools==2.1.0
# via
# -r requirements/edx/base.txt
# edx-name-affirmation
edx-auth-backends==4.6.0
edx-auth-backends==4.6.1
# via -r requirements/edx/base.txt
edx-bulk-grades==1.2.0
# via
@@ -524,7 +524,7 @@ edx-django-release-util==1.5.0
# edxval
edx-django-sites-extensions==5.1.0
# via -r requirements/edx/base.txt
edx-django-utils==8.0.0
edx-django-utils==8.0.1
# via
# -r requirements/edx/base.txt
# django-config-models
@@ -612,7 +612,7 @@ edx-search==4.3.0
# openedx-forum
edx-sga==0.26.0
# via -r requirements/edx/base.txt
edx-submissions==3.11.1
edx-submissions==3.12.0
# via
# -r requirements/edx/base.txt
# ora2
@@ -653,7 +653,7 @@ enmerkar==0.7.1
# enmerkar-underscore
enmerkar-underscore==2.4.0
# via -r requirements/edx/base.txt
enterprise-integrated-channels==0.1.16
enterprise-integrated-channels==0.1.18
# via -r requirements/edx/base.txt
event-tracking==3.3.0
# via
@@ -673,7 +673,7 @@ firebase-admin==7.1.0
# via
# -r requirements/edx/base.txt
# edx-ace
frozenlist==1.7.0
frozenlist==1.8.0
# via
# -r requirements/edx/base.txt
# aiohttp
@@ -696,14 +696,14 @@ gitpython==3.1.45
# via -r requirements/edx/doc.in
glob2==0.7
# via -r requirements/edx/base.txt
google-api-core[grpc]==2.25.1
google-api-core[grpc]==2.25.2
# via
# -r requirements/edx/base.txt
# firebase-admin
# google-cloud-core
# google-cloud-firestore
# google-cloud-storage
google-auth==2.40.3
google-auth==2.41.1
# via
# -r requirements/edx/base.txt
# google-api-core
@@ -737,12 +737,12 @@ googleapis-common-protos==1.70.0
# -r requirements/edx/base.txt
# google-api-core
# grpcio-status
grpcio==1.74.0
grpcio==1.75.1
# via
# -r requirements/edx/base.txt
# google-api-core
# grpcio-status
grpcio-status==1.74.0
grpcio-status==1.75.1
# via
# -r requirements/edx/base.txt
# google-api-core
@@ -885,7 +885,7 @@ lxml[html-clean]==5.3.2
# python3-saml
# xblock
# xmlsec
lxml-html-clean==0.4.2
lxml-html-clean==0.4.3
# via
# -r requirements/edx/base.txt
# lxml
@@ -904,7 +904,7 @@ markdown==3.9
# openedx-django-wiki
# staff-graded-xblock
# xblock-poll
markupsafe==3.0.2
markupsafe==3.0.3
# via
# -r requirements/edx/base.txt
# chem
@@ -940,7 +940,7 @@ msgpack==1.1.1
# via
# -r requirements/edx/base.txt
# cachecontrol
multidict==6.6.4
multidict==6.7.0
# via
# -r requirements/edx/base.txt
# aiohttp
@@ -953,7 +953,7 @@ nh3==0.3.0
# via
# -r requirements/edx/base.txt
# xblocks-contrib
nltk==3.9.1
nltk==3.9.2
# via
# -r requirements/edx/base.txt
# chem
@@ -1074,7 +1074,7 @@ prompt-toolkit==3.0.52
# via
# -r requirements/edx/base.txt
# click-repl
propcache==0.3.2
propcache==0.4.0
# via
# -r requirements/edx/base.txt
# aiohttp
@@ -1092,7 +1092,7 @@ protobuf==6.32.1
# googleapis-common-protos
# grpcio-status
# proto-plus
psutil==7.0.0
psutil==7.1.0
# via
# -r requirements/edx/base.txt
# edx-django-utils
@@ -1117,7 +1117,7 @@ pycryptodomex==3.23.0
# -r requirements/edx/base.txt
# edx-proctoring
# lti-consumer-xblock
pydantic==2.11.9
pydantic==2.11.10
# via
# -r requirements/edx/base.txt
# camel-converter
@@ -1169,11 +1169,11 @@ pynacl==1.6.0
# paramiko
pynliner==0.8.0
# via -r requirements/edx/base.txt
pyopenssl==25.2.0
pyopenssl==25.3.0
# via
# -r requirements/edx/base.txt
# snowflake-connector-python
pyparsing==3.2.4
pyparsing==3.2.5
# via
# -r requirements/edx/base.txt
# chem
@@ -1234,7 +1234,7 @@ pytz==2025.2
# xblock
pyuca==1.2
# via -r requirements/edx/base.txt
pyyaml==6.0.2
pyyaml==6.0.3
# via
# -r requirements/edx/base.txt
# code-annotations
@@ -1259,7 +1259,7 @@ referencing==0.36.2
# -r requirements/edx/base.txt
# jsonschema
# jsonschema-specifications
regex==2025.9.1
regex==2025.9.18
# via
# -r requirements/edx/base.txt
# nltk
@@ -1328,9 +1328,9 @@ semantic-version==2.10.0
# via
# -r requirements/edx/base.txt
# edx-drf-extensions
shapely==2.1.1
shapely==2.1.2
# via -r requirements/edx/base.txt
simplejson==3.20.1
simplejson==3.20.2
# via
# -r requirements/edx/base.txt
# sailthru-client
@@ -1369,7 +1369,7 @@ sniffio==1.3.1
# anyio
snowballstemmer==3.0.1
# via sphinx
snowflake-connector-python==3.17.3
snowflake-connector-python==3.18.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -1407,7 +1407,7 @@ sphinx==8.2.3
# sphinxcontrib-httpdomain
# sphinxcontrib-openapi
# sphinxext-rediraffe
sphinx-autoapi==3.6.0
sphinx-autoapi==3.6.1
# via -r requirements/edx/doc.in
sphinx-book-theme==1.1.4
# via -r requirements/edx/doc.in
@@ -1433,7 +1433,7 @@ sphinxcontrib-qthelp==2.0.0
# via sphinx
sphinxcontrib-serializinghtml==2.0.0
# via sphinx
sphinxext-rediraffe==0.2.7
sphinxext-rediraffe==0.3.0
# via -r requirements/edx/doc.in
sqlparse==0.5.3
# via
@@ -1487,6 +1487,7 @@ typing-extensions==4.15.0
# beautifulsoup4
# django-countries
# edx-opaque-keys
# grpcio
# jwcrypto
# pydantic
# pydantic-core
@@ -1496,7 +1497,7 @@ typing-extensions==4.15.0
# referencing
# snowflake-connector-python
# typing-inspection
typing-inspection==0.4.1
typing-inspection==0.4.2
# via
# -r requirements/edx/base.txt
# pydantic
@@ -1523,8 +1524,6 @@ urllib3==2.5.0
# botocore
# elasticsearch
# requests
user-util==2.0.0
# via -r requirements/edx/base.txt
vine==5.1.0
# via
# -r requirements/edx/base.txt
@@ -1539,7 +1538,7 @@ walrus==0.9.5
# via
# -r requirements/edx/base.txt
# edx-event-bus-redis
wcwidth==0.2.13
wcwidth==0.2.14
# via
# -r requirements/edx/base.txt
# prompt-toolkit
@@ -1603,7 +1602,7 @@ xmlsec==1.3.14
# python3-saml
xss-utils==0.8.0
# via -r requirements/edx/base.txt
yarl==1.20.1
yarl==1.22.0
# via
# -r requirements/edx/base.txt
# aiohttp

View File

@@ -155,7 +155,6 @@ sorl-thumbnail
sortedcontainers # Provides SortedKeyList, used for lists of XBlock assets
stevedore # Support for runtime plugins, used for XBlocks and edx-platform Django app plugins
unicodecsv # Easier support for CSV files with unicode text
user-util # Functionality for retiring users (GDPR compliance)
webob
web-fragments # Provides the ability to render fragments of web pages
wrapt # Better functools.wrapped. TODO: functools has since improved, maybe we can switch?

View File

@@ -4,7 +4,15 @@
#
# make upgrade
#
attrs==25.3.0
annotated-types==0.7.0
# via pydantic
anyio==4.11.0
# via
# httpx
# mcp
# sse-starlette
# starlette
attrs==25.4.0
# via
# glom
# jsonschema
@@ -17,24 +25,24 @@ boltons==21.0.0
# semgrep
bracex==2.6
# via wcmatch
certifi==2025.8.3
# via requests
certifi==2025.10.5
# via
# httpcore
# httpx
# requests
charset-normalizer==3.4.3
# via requests
click==8.1.8
# via
# click-option-group
# semgrep
click-option-group==0.5.7
# uvicorn
click-option-group==0.5.8
# via semgrep
colorama==0.4.6
# via semgrep
defusedxml==0.7.1
# via semgrep
deprecated==1.2.18
# via
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-http
exceptiongroup==1.2.2
# via semgrep
face==24.0.0
@@ -43,19 +51,36 @@ glom==22.1.0
# via semgrep
googleapis-common-protos==1.70.0
# via opentelemetry-exporter-otlp-proto-http
h11==0.16.0
# via
# httpcore
# uvicorn
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via mcp
httpx-sse==0.4.1
# via mcp
idna==3.10
# via requests
importlib-metadata==7.1.0
# via
# anyio
# httpx
# requests
importlib-metadata==8.7.0
# via opentelemetry-api
jsonschema==4.25.1
# via semgrep
jsonschema==4.20.0
# via
# mcp
# semgrep
jsonschema-specifications==2025.9.1
# via jsonschema
markdown-it-py==4.0.0
# via rich
mcp==1.12.2
# via semgrep
mdurl==0.1.2
# via markdown-it-py
opentelemetry-api==1.25.0
opentelemetry-api==1.37.0
# via
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-instrumentation
@@ -63,38 +88,53 @@ opentelemetry-api==1.25.0
# opentelemetry-sdk
# opentelemetry-semantic-conventions
# semgrep
opentelemetry-exporter-otlp-proto-common==1.25.0
opentelemetry-exporter-otlp-proto-common==1.37.0
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-http==1.25.0
opentelemetry-exporter-otlp-proto-http==1.37.0
# via semgrep
opentelemetry-instrumentation==0.46b0
opentelemetry-instrumentation==0.58b0
# via opentelemetry-instrumentation-requests
opentelemetry-instrumentation-requests==0.46b0
opentelemetry-instrumentation-requests==0.58b0
# via semgrep
opentelemetry-proto==1.25.0
opentelemetry-proto==1.37.0
# via
# opentelemetry-exporter-otlp-proto-common
# opentelemetry-exporter-otlp-proto-http
opentelemetry-sdk==1.25.0
opentelemetry-sdk==1.37.0
# via
# opentelemetry-exporter-otlp-proto-http
# semgrep
opentelemetry-semantic-conventions==0.46b0
opentelemetry-semantic-conventions==0.58b0
# via
# opentelemetry-instrumentation
# opentelemetry-instrumentation-requests
# opentelemetry-sdk
opentelemetry-util-http==0.46b0
opentelemetry-util-http==0.58b0
# via opentelemetry-instrumentation-requests
packaging==25.0
# via semgrep
# via
# opentelemetry-instrumentation
# semgrep
peewee==3.18.2
# via semgrep
protobuf==4.25.8
protobuf==6.32.1
# via
# googleapis-common-protos
# opentelemetry-proto
pydantic==2.11.10
# via
# mcp
# pydantic-settings
pydantic-core==2.33.2
# via pydantic
pydantic-settings==2.11.0
# via mcp
pygments==2.19.2
# via rich
python-dotenv==1.1.1
# via pydantic-settings
python-multipart==0.0.20
# via mcp
referencing==0.36.2
# via
# jsonschema
@@ -112,28 +152,45 @@ rpds-py==0.27.1
ruamel-yaml==0.18.15
# via semgrep
ruamel-yaml-clib==0.2.12
# via ruamel-yaml
semgrep==1.136.0
# via
# ruamel-yaml
# semgrep
semgrep==1.139.0
# via -r requirements/edx/semgrep.in
sniffio==1.3.1
# via anyio
sse-starlette==3.0.2
# via mcp
starlette==0.48.0
# via mcp
tomli==2.0.2
# via semgrep
typing-extensions==4.15.0
# via
# anyio
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
# opentelemetry-semantic-conventions
# pydantic
# pydantic-core
# referencing
# semgrep
# starlette
# typing-inspection
typing-inspection==0.4.2
# via
# pydantic
# pydantic-settings
urllib3==2.5.0
# via
# requests
# semgrep
uvicorn==0.37.0
# via mcp
wcmatch==8.5.2
# via semgrep
wrapt==1.17.3
# via
# deprecated
# opentelemetry-instrumentation
# via opentelemetry-instrumentation
zipp==3.23.0
# via importlib-metadata
# The following packages are considered to be unsafe in a requirements file:
# setuptools

View File

@@ -10,7 +10,7 @@ aiohappyeyeballs==2.6.1
# via
# -r requirements/edx/base.txt
# aiohttp
aiohttp==3.12.15
aiohttp==3.13.0
# via
# -r requirements/edx/base.txt
# geoip2
@@ -33,7 +33,7 @@ annotated-types==0.7.0
# via
# -r requirements/edx/base.txt
# pydantic
anyio==4.10.0
anyio==4.11.0
# via
# -r requirements/edx/base.txt
# httpx
@@ -42,7 +42,7 @@ appdirs==1.4.4
# via
# -r requirements/edx/base.txt
# fs
asgiref==3.9.1
asgiref==3.10.0
# via
# -r requirements/edx/base.txt
# django
@@ -56,7 +56,7 @@ astroid==3.3.11
# via
# pylint
# pylint-celery
attrs==25.3.0
attrs==25.4.0
# via
# -r requirements/edx/base.txt
# aiohttp
@@ -75,17 +75,17 @@ backoff==1.10.0
# via
# -r requirements/edx/base.txt
# analytics-python
bcrypt==4.3.0
bcrypt==5.0.0
# via
# -r requirements/edx/base.txt
# paramiko
beautifulsoup4==4.13.5
beautifulsoup4==4.14.2
# via
# -r requirements/edx/base.txt
# -r requirements/edx/testing.in
# openedx-forum
# pynliner
billiard==4.2.1
billiard==4.2.2
# via
# -r requirements/edx/base.txt
# celery
@@ -100,14 +100,14 @@ bleach[css]==6.2.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/base.txt
boto3==1.40.31
boto3==1.40.46
# via
# -r requirements/edx/base.txt
# django-ses
# fs-s3fs
# ora2
# snowflake-connector-python
botocore==1.40.31
botocore==1.40.46
# via
# -r requirements/edx/base.txt
# boto3
@@ -119,7 +119,7 @@ cachecontrol==0.14.3
# via
# -r requirements/edx/base.txt
# firebase-admin
cachetools==5.5.2
cachetools==6.2.0
# via
# -r requirements/edx/base.txt
# edxval
@@ -140,7 +140,7 @@ celery==5.5.3
# enterprise-integrated-channels
# event-tracking
# openedx-learning
certifi==2025.8.3
certifi==2025.10.5
# via
# -r requirements/edx/base.txt
# elasticsearch
@@ -169,7 +169,7 @@ charset-normalizer==3.4.3
# snowflake-connector-python
chem==2.0.0
# via -r requirements/edx/base.txt
click==8.2.1
click==8.3.0
# via
# -r requirements/edx/base.txt
# celery
@@ -183,7 +183,6 @@ click==8.2.1
# import-linter
# nltk
# pact-python
# user-util
# uvicorn
click-didyoumean==0.3.1
# via
@@ -210,7 +209,7 @@ codejail-includes==2.0.0
# via -r requirements/edx/base.txt
colorama==0.4.6
# via tox
coverage[toml]==7.10.6
coverage[toml]==7.10.7
# via
# -r requirements/edx/coverage.txt
# pytest-cov
@@ -218,6 +217,7 @@ crowdsourcehinter-xblock==0.8
# via -r requirements/edx/base.txt
cryptography==45.0.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/base.txt
# django-fernet-fields-v2
# edx-enterprise
@@ -245,7 +245,7 @@ defusedxml==0.7.1
# ora2
# python3-openid
# social-auth-core
diff-cover==9.6.0
diff-cover==9.7.1
# via -r requirements/edx/coverage.txt
dill==0.4.0
# via pylint
@@ -347,7 +347,7 @@ django-config-models==2.9.0
# edx-name-affirmation
# enterprise-integrated-channels
# lti-consumer-xblock
django-cors-headers==4.8.0
django-cors-headers==4.9.0
# via -r requirements/edx/base.txt
django-countries==7.6.1
# via
@@ -410,7 +410,7 @@ django-multi-email-field==0.8.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
django-mysql==4.18.0
django-mysql==4.19.0
# via -r requirements/edx/base.txt
django-oauth-toolkit==1.7.1
# via
@@ -507,7 +507,7 @@ drf-jwt==1.19.2
# edx-drf-extensions
drf-spectacular==0.28.0
# via -r requirements/edx/base.txt
drf-yasg==1.21.10
drf-yasg==1.21.11
# via
# -r requirements/edx/base.txt
# django-user-tasks
@@ -518,7 +518,7 @@ edx-api-doc-tools==2.1.0
# via
# -r requirements/edx/base.txt
# edx-name-affirmation
edx-auth-backends==4.6.0
edx-auth-backends==4.6.1
# via -r requirements/edx/base.txt
edx-bulk-grades==1.2.0
# via
@@ -545,7 +545,7 @@ edx-django-release-util==1.5.0
# edxval
edx-django-sites-extensions==5.1.0
# via -r requirements/edx/base.txt
edx-django-utils==8.0.0
edx-django-utils==8.0.1
# via
# -r requirements/edx/base.txt
# django-config-models
@@ -635,7 +635,7 @@ edx-search==4.3.0
# openedx-forum
edx-sga==0.26.0
# via -r requirements/edx/base.txt
edx-submissions==3.11.1
edx-submissions==3.12.0
# via
# -r requirements/edx/base.txt
# ora2
@@ -676,7 +676,7 @@ enmerkar==0.7.1
# enmerkar-underscore
enmerkar-underscore==2.4.0
# via -r requirements/edx/base.txt
enterprise-integrated-channels==0.1.16
enterprise-integrated-channels==0.1.18
# via -r requirements/edx/base.txt
event-tracking==3.3.0
# via
@@ -690,7 +690,7 @@ factory-boy==3.3.3
# via -r requirements/edx/testing.in
faker==37.8.0
# via factory-boy
fastapi==0.116.1
fastapi==0.118.0
# via pact-python
fastavro==1.12.0
# via
@@ -708,7 +708,7 @@ firebase-admin==7.1.0
# edx-ace
freezegun==1.5.5
# via -r requirements/edx/testing.in
frozenlist==1.7.0
frozenlist==1.8.0
# via
# -r requirements/edx/base.txt
# aiohttp
@@ -727,14 +727,14 @@ geoip2==5.1.0
# via -r requirements/edx/base.txt
glob2==0.7
# via -r requirements/edx/base.txt
google-api-core[grpc]==2.25.1
google-api-core[grpc]==2.25.2
# via
# -r requirements/edx/base.txt
# firebase-admin
# google-cloud-core
# google-cloud-firestore
# google-cloud-storage
google-auth==2.40.3
google-auth==2.41.1
# via
# -r requirements/edx/base.txt
# google-api-core
@@ -770,12 +770,12 @@ googleapis-common-protos==1.70.0
# grpcio-status
grimp==3.11
# via import-linter
grpcio==1.74.0
grpcio==1.75.1
# via
# -r requirements/edx/base.txt
# google-api-core
# grpcio-status
grpcio-status==1.74.0
grpcio-status==1.75.1
# via
# -r requirements/edx/base.txt
# google-api-core
@@ -846,7 +846,7 @@ isodate==0.7.2
# via
# -r requirements/edx/base.txt
# python3-saml
isort==6.0.1
isort==6.1.0
# via
# -r requirements/edx/testing.in
# pylint
@@ -927,7 +927,7 @@ lxml[html-clean]==5.3.2
# python3-saml
# xblock
# xmlsec
lxml-html-clean==0.4.2
lxml-html-clean==0.4.3
# via
# -r requirements/edx/base.txt
# lxml
@@ -946,7 +946,7 @@ markdown==3.9
# openedx-django-wiki
# staff-graded-xblock
# xblock-poll
markupsafe==3.0.2
markupsafe==3.0.3
# via
# -r requirements/edx/base.txt
# -r requirements/edx/coverage.txt
@@ -985,7 +985,7 @@ msgpack==1.1.1
# via
# -r requirements/edx/base.txt
# cachecontrol
multidict==6.6.4
multidict==6.7.0
# via
# -r requirements/edx/base.txt
# aiohttp
@@ -998,7 +998,7 @@ nh3==0.3.0
# via
# -r requirements/edx/base.txt
# xblocks-contrib
nltk==3.9.1
nltk==3.9.2
# via
# -r requirements/edx/base.txt
# chem
@@ -1079,7 +1079,9 @@ packaging==25.0
# snowflake-connector-python
# tox
pact-python==2.3.3
# via -r requirements/edx/testing.in
# via
# -c requirements/constraints.txt
# -r requirements/edx/testing.in
paramiko==4.0.0
# via
# -r requirements/edx/base.txt
@@ -1131,7 +1133,7 @@ prompt-toolkit==3.0.52
# via
# -r requirements/edx/base.txt
# click-repl
propcache==0.3.2
propcache==0.4.0
# via
# -r requirements/edx/base.txt
# aiohttp
@@ -1149,7 +1151,7 @@ protobuf==6.32.1
# googleapis-common-protos
# grpcio-status
# proto-plus
psutil==7.0.0
psutil==7.1.0
# via
# -r requirements/edx/base.txt
# edx-django-utils
@@ -1182,7 +1184,7 @@ pycryptodomex==3.23.0
# -r requirements/edx/base.txt
# edx-proctoring
# lti-consumer-xblock
pydantic==2.11.9
pydantic==2.11.10
# via
# -r requirements/edx/base.txt
# camel-converter
@@ -1212,7 +1214,7 @@ pylatexenc==2.10
# via
# -r requirements/edx/base.txt
# olxcleaner
pylint==3.3.8
pylint==3.3.9
# via
# edx-lint
# pylint-celery
@@ -1248,11 +1250,11 @@ pynacl==1.6.0
# paramiko
pynliner==0.8.0
# via -r requirements/edx/base.txt
pyopenssl==25.2.0
pyopenssl==25.3.0
# via
# -r requirements/edx/base.txt
# snowflake-connector-python
pyparsing==3.2.4
pyparsing==3.2.5
# via
# -r requirements/edx/base.txt
# chem
@@ -1345,7 +1347,7 @@ pytz==2025.2
# xblock
pyuca==1.2
# via -r requirements/edx/base.txt
pyyaml==6.0.2
pyyaml==6.0.3
# via
# -r requirements/edx/base.txt
# code-annotations
@@ -1368,7 +1370,7 @@ referencing==0.36.2
# -r requirements/edx/base.txt
# jsonschema
# jsonschema-specifications
regex==2025.9.1
regex==2025.9.18
# via
# -r requirements/edx/base.txt
# nltk
@@ -1435,9 +1437,9 @@ semantic-version==2.10.0
# via
# -r requirements/edx/base.txt
# edx-drf-extensions
shapely==2.1.1
shapely==2.1.2
# via -r requirements/edx/base.txt
simplejson==3.20.1
simplejson==3.20.2
# via
# -r requirements/edx/base.txt
# sailthru-client
@@ -1475,7 +1477,7 @@ sniffio==1.3.1
# via
# -r requirements/edx/base.txt
# anyio
snowflake-connector-python==3.17.3
snowflake-connector-python==3.18.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -1507,7 +1509,7 @@ sqlparse==0.5.3
# django
staff-graded-xblock==3.1.0
# via -r requirements/edx/base.txt
starlette==0.47.3
starlette==0.48.0
# via fastapi
stevedore==5.5.0
# via
@@ -1544,7 +1546,7 @@ tomlkit==0.13.3
# openedx-learning
# pylint
# snowflake-connector-python
tox==4.27.0
tox==4.30.3
# via -r requirements/edx/testing.in
tqdm==4.67.1
# via
@@ -1561,6 +1563,7 @@ typing-extensions==4.15.0
# edx-opaque-keys
# fastapi
# grimp
# grpcio
# import-linter
# jwcrypto
# pydantic
@@ -1571,7 +1574,7 @@ typing-extensions==4.15.0
# snowflake-connector-python
# starlette
# typing-inspection
typing-inspection==0.4.1
typing-inspection==0.4.2
# via
# -r requirements/edx/base.txt
# pydantic
@@ -1601,9 +1604,7 @@ urllib3==2.5.0
# botocore
# elasticsearch
# requests
user-util==2.0.0
# via -r requirements/edx/base.txt
uvicorn==0.35.0
uvicorn==0.37.0
# via pact-python
vine==5.1.0
# via
@@ -1621,7 +1622,7 @@ walrus==0.9.5
# via
# -r requirements/edx/base.txt
# edx-event-bus-redis
wcwidth==0.2.13
wcwidth==0.2.14
# via
# -r requirements/edx/base.txt
# prompt-toolkit
@@ -1685,7 +1686,7 @@ xmlsec==1.3.14
# python3-saml
xss-utils==0.8.0
# via -r requirements/edx/base.txt
yarl==1.20.1
yarl==1.22.0
# via
# -r requirements/edx/base.txt
# aiohttp

View File

@@ -6,11 +6,11 @@
#
build==1.3.0
# via pip-tools
click==8.2.1
click==8.3.0
# via pip-tools
packaging==25.0
# via build
pip-tools==7.5.0
pip-tools==7.5.1
# via -r requirements/pip-tools.in
pyproject-hooks==1.2.0
# via

View File

@@ -4,7 +4,7 @@
#
# make upgrade
#
click==8.2.1
click==8.3.0
# via
# -r scripts/structures_pruning/requirements/base.in
# click-log

View File

@@ -4,7 +4,7 @@
#
# make upgrade
#
click==8.2.1
click==8.3.0
# via
# -r scripts/structures_pruning/requirements/base.txt
# click-log

View File

@@ -4,21 +4,21 @@
#
# make upgrade
#
asgiref==3.9.1
asgiref==3.10.0
# via django
attrs==25.3.0
attrs==25.4.0
# via zeep
backoff==2.2.1
# via -r scripts/user_retirement/requirements/base.in
boto3==1.40.31
boto3==1.40.46
# via -r scripts/user_retirement/requirements/base.in
botocore==1.40.31
botocore==1.40.46
# via
# boto3
# s3transfer
cachetools==5.5.2
cachetools==6.2.0
# via google-auth
certifi==2025.8.3
certifi==2025.10.5
# via requests
cffi==2.0.0
# via
@@ -26,7 +26,7 @@ cffi==2.0.0
# pynacl
charset-normalizer==3.4.3
# via requests
click==8.2.1
click==8.3.0
# via
# -r scripts/user_retirement/requirements/base.in
# edx-django-utils
@@ -34,6 +34,7 @@ cryptography==45.0.7
# via pyjwt
django==5.2.6
# via
# -c requirements/constraints.txt
# django-crum
# django-waffle
# edx-django-utils
@@ -41,15 +42,15 @@ django-crum==0.7.9
# via edx-django-utils
django-waffle==5.0.0
# via edx-django-utils
edx-django-utils==8.0.0
edx-django-utils==8.0.1
# via edx-rest-api-client
edx-rest-api-client==6.2.0
# via -r scripts/user_retirement/requirements/base.in
google-api-core==2.25.1
google-api-core==2.25.2
# via google-api-python-client
google-api-python-client==2.181.0
google-api-python-client==2.184.0
# via -r scripts/user_retirement/requirements/base.in
google-auth==2.40.3
google-auth==2.41.1
# via
# google-api-core
# google-api-python-client
@@ -87,7 +88,7 @@ protobuf==6.32.1
# google-api-core
# googleapis-common-protos
# proto-plus
psutil==7.0.0
psutil==7.1.0
# via edx-django-utils
pyasn1==0.6.1
# via
@@ -103,7 +104,7 @@ pyjwt[crypto]==2.10.1
# simple-salesforce
pynacl==1.6.0
# via edx-django-utils
pyparsing==3.2.4
pyparsing==3.2.5
# via httplib2
python-dateutil==2.9.0.post0
# via botocore
@@ -111,7 +112,7 @@ pytz==2025.2
# via
# jenkinsapi
# zeep
pyyaml==6.0.2
pyyaml==6.0.3
# via -r scripts/user_retirement/requirements/base.in
requests==2.32.5
# via
@@ -133,7 +134,7 @@ s3transfer==0.14.0
# via boto3
simple-salesforce==1.12.9
# via -r scripts/user_retirement/requirements/base.in
simplejson==3.20.1
simplejson==3.20.2
# via -r scripts/user_retirement/requirements/base.in
six==1.17.0
# via python-dateutil

View File

@@ -4,31 +4,31 @@
#
# make upgrade
#
asgiref==3.9.1
asgiref==3.10.0
# via
# -r scripts/user_retirement/requirements/base.txt
# django
attrs==25.3.0
attrs==25.4.0
# via
# -r scripts/user_retirement/requirements/base.txt
# zeep
backoff==2.2.1
# via -r scripts/user_retirement/requirements/base.txt
boto3==1.40.31
boto3==1.40.46
# via
# -r scripts/user_retirement/requirements/base.txt
# moto
botocore==1.40.31
botocore==1.40.46
# via
# -r scripts/user_retirement/requirements/base.txt
# boto3
# moto
# s3transfer
cachetools==5.5.2
cachetools==6.2.0
# via
# -r scripts/user_retirement/requirements/base.txt
# google-auth
certifi==2025.8.3
certifi==2025.10.5
# via
# -r scripts/user_retirement/requirements/base.txt
# requests
@@ -41,7 +41,7 @@ charset-normalizer==3.4.3
# via
# -r scripts/user_retirement/requirements/base.txt
# requests
click==8.2.1
click==8.3.0
# via
# -r scripts/user_retirement/requirements/base.txt
# edx-django-utils
@@ -66,19 +66,19 @@ django-waffle==5.0.0
# via
# -r scripts/user_retirement/requirements/base.txt
# edx-django-utils
edx-django-utils==8.0.0
edx-django-utils==8.0.1
# via
# -r scripts/user_retirement/requirements/base.txt
# edx-rest-api-client
edx-rest-api-client==6.2.0
# via -r scripts/user_retirement/requirements/base.txt
google-api-core==2.25.1
google-api-core==2.25.2
# via
# -r scripts/user_retirement/requirements/base.txt
# google-api-python-client
google-api-python-client==2.181.0
google-api-python-client==2.184.0
# via -r scripts/user_retirement/requirements/base.txt
google-auth==2.40.3
google-auth==2.41.1
# via
# -r scripts/user_retirement/requirements/base.txt
# google-api-core
@@ -120,7 +120,7 @@ lxml==5.3.2
# via
# -r scripts/user_retirement/requirements/base.txt
# zeep
markupsafe==3.0.2
markupsafe==3.0.3
# via
# jinja2
# werkzeug
@@ -130,7 +130,7 @@ more-itertools==10.8.0
# via
# -r scripts/user_retirement/requirements/base.txt
# simple-salesforce
moto==5.1.12
moto==5.1.14
# via -r scripts/user_retirement/requirements/testing.in
packaging==25.0
# via pytest
@@ -150,7 +150,7 @@ protobuf==6.32.1
# google-api-core
# googleapis-common-protos
# proto-plus
psutil==7.0.0
psutil==7.1.0
# via
# -r scripts/user_retirement/requirements/base.txt
# edx-django-utils
@@ -178,7 +178,7 @@ pynacl==1.6.0
# via
# -r scripts/user_retirement/requirements/base.txt
# edx-django-utils
pyparsing==3.2.4
pyparsing==3.2.5
# via
# -r scripts/user_retirement/requirements/base.txt
# httplib2
@@ -194,7 +194,7 @@ pytz==2025.2
# -r scripts/user_retirement/requirements/base.txt
# jenkinsapi
# zeep
pyyaml==6.0.2
pyyaml==6.0.3
# via
# -r scripts/user_retirement/requirements/base.txt
# responses
@@ -235,7 +235,7 @@ s3transfer==0.14.0
# boto3
simple-salesforce==1.12.9
# via -r scripts/user_retirement/requirements/base.txt
simplejson==3.20.1
simplejson==3.20.2
# via -r scripts/user_retirement/requirements/base.txt
six==1.17.0
# via
@@ -268,7 +268,7 @@ urllib3==2.5.0
# responses
werkzeug==3.1.3
# via moto
xmltodict==1.0.0
xmltodict==1.0.2
# via moto
zeep==4.3.2
# via

View File

@@ -4,7 +4,7 @@
#
# make upgrade
#
certifi==2025.8.3
certifi==2025.10.5
# via requests
charset-normalizer==3.4.3
# via requests