diff --git a/.github/workflows/check-consistent-dependencies.yml b/.github/workflows/check-consistent-dependencies.yml
index c3f35d92a0..46f801179e 100644
--- a/.github/workflows/check-consistent-dependencies.yml
+++ b/.github/workflows/check-consistent-dependencies.yml
@@ -7,6 +7,7 @@ name: Consistent Python dependencies
on:
pull_request:
+ merge_group:
defaults:
run:
@@ -18,26 +19,31 @@ jobs:
runs-on: ubuntu-24.04
steps:
- # Only run remaining steps if there are changes to requirements/**
- - name: "Decide whether to short-circuit"
- env:
- GH_TOKEN: "${{ github.token }}"
- PR_URL: "${{ github.event.pull_request.html_url }}"
- run: |
- paths=$(gh pr diff "$PR_URL" --name-only)
- echo $'Paths touched in PR:\n'"$paths"
+ # Always checkout the code because we don't always have a PR url.
+ - uses: actions/checkout@v5
- # The ^"? is because git may quote weird file paths
- matched="$(echo "$paths" | grep -P '^"?((requirements/)|(scripts/.*?/requirements/))' || true)"
- echo $'Relevant paths:\n'"$matched"
- if [[ -n "$matched" ]]; then
- echo "RELEVANT=true" >> "$GITHUB_ENV"
+ # Only run remaining steps if there are changes to requirements/**
+ # We do this instead of using path based short-circuiting.
+ # see https://stackoverflow.com/questions/77996177/how-can-i-handle-a-required-check-that-isnt-always-triggered
+ # for some more details.
+ - name: "Decide whether to short-circuit"
+ run: |
+ if [[ "${{ github.event_name }}" == "pull_request" ]]; then
+ BASE_SHA="${{ github.event.pull_request.base.sha }}"
+ else
+ BASE_SHA="${{ github.event.merge_group.base_sha }}"
fi
- - uses: actions/checkout@v5
- if: ${{ env.RELEVANT == 'true' }}
+ # Fetch the base sha so we can compare to it. It's not checked out by
+ # default.
+ git fetch origin "$BASE_SHA"
- - uses: actions/setup-python@v5
+ # The ^"? is because git may quote weird file paths
+ if git diff --name-only "$BASE_SHA" | grep -P '^"?((requirements/)|(scripts/.*?/requirements/))'; then
+ echo "RELEVANT=true" >> "$GITHUB_ENV"
+ fi
+
+ - uses: actions/setup-python@v6
if: ${{ env.RELEVANT == 'true' }}
with:
python-version: '3.11'
diff --git a/.github/workflows/check-for-tutorial-prs.yml b/.github/workflows/check-for-tutorial-prs.yml
index 1dc4d38609..e3969a1920 100644
--- a/.github/workflows/check-for-tutorial-prs.yml
+++ b/.github/workflows/check-for-tutorial-prs.yml
@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@v5
- name: Comment PR
- uses: thollander/actions-comment-pull-request@v2
+ uses: thollander/actions-comment-pull-request@v3
with:
message: |
Thank you for your pull request! Congratulations on completing the Open edX tutorial! A team member will be by to take a look shortly.
diff --git a/.github/workflows/check_python_dependencies.yml b/.github/workflows/check_python_dependencies.yml
index 7b93a545cd..f215880f0e 100644
--- a/.github/workflows/check_python_dependencies.yml
+++ b/.github/workflows/check_python_dependencies.yml
@@ -2,6 +2,7 @@ name: Check Python Dependencies
on:
pull_request:
+ merge_group:
jobs:
check_dependencies:
@@ -16,7 +17,7 @@ jobs:
uses: actions/checkout@v5
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/ci-static-analysis.yml b/.github/workflows/ci-static-analysis.yml
index d2513ba210..deb2853899 100644
--- a/.github/workflows/ci-static-analysis.yml
+++ b/.github/workflows/ci-static-analysis.yml
@@ -1,6 +1,8 @@
name: Static analysis
-on: pull_request
+on:
+ pull_request:
+ merge_group:
jobs:
tests:
@@ -15,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml
index fec11d6c25..03b0c6c133 100644
--- a/.github/workflows/commitlint.yml
+++ b/.github/workflows/commitlint.yml
@@ -3,7 +3,8 @@
name: Lint Commit Messages
on:
- - pull_request
+ pull_request:
+ merge_group:
jobs:
commitlint:
diff --git a/.github/workflows/compile-python-requirements.yml b/.github/workflows/compile-python-requirements.yml
index 8673cc3c23..9b4eb7f797 100644
--- a/.github/workflows/compile-python-requirements.yml
+++ b/.github/workflows/compile-python-requirements.yml
@@ -24,7 +24,7 @@ jobs:
ref: "${{ inputs.branch }}"
- name: Set up Python environment
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: "3.11"
diff --git a/.github/workflows/docker-compose.yml.mysqldbdump b/.github/workflows/docker-compose.yml.mysqldbdump
deleted file mode 100644
index 87f0321374..0000000000
--- a/.github/workflows/docker-compose.yml.mysqldbdump
+++ /dev/null
@@ -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
diff --git a/.github/workflows/init/01.sql b/.github/workflows/init/01.sql
deleted file mode 100644
index 93d3a107e3..0000000000
--- a/.github/workflows/init/01.sql
+++ /dev/null
@@ -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';
diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml
index 94a1368e96..4eadf575f4 100644
--- a/.github/workflows/js-tests.yml
+++ b/.github/workflows/js-tests.yml
@@ -2,6 +2,7 @@ name: Javascript tests
on:
pull_request:
+ merge_group:
push:
branches:
- master
@@ -23,7 +24,7 @@ jobs:
run: git fetch --depth=1 origin master
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
@@ -43,7 +44,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install libxmlsec1-dev ubuntu-restricted-extras xvfb
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/lint-imports.yml b/.github/workflows/lint-imports.yml
index baf914298b..03034b465a 100644
--- a/.github/workflows/lint-imports.yml
+++ b/.github/workflows/lint-imports.yml
@@ -2,6 +2,7 @@ name: Lint Python Imports
on:
pull_request:
+ merge_group:
push:
branches:
- master
@@ -16,7 +17,7 @@ jobs:
uses: actions/checkout@v5
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: "3.11"
diff --git a/.github/workflows/lockfileversion-check.yml b/.github/workflows/lockfileversion-check.yml
index 736f1f98de..1ebd22b9ad 100644
--- a/.github/workflows/lockfileversion-check.yml
+++ b/.github/workflows/lockfileversion-check.yml
@@ -7,6 +7,7 @@ on:
branches:
- master
pull_request:
+ merge_group:
jobs:
version-check:
diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml
index cd4d09589c..ffbc816cbe 100644
--- a/.github/workflows/migrations-check.yml
+++ b/.github/workflows/migrations-check.yml
@@ -3,6 +3,7 @@ name: Check Django Migrations
on:
workflow_dispatch:
pull_request:
+ merge_group:
push:
branches:
- master
@@ -73,7 +74,7 @@ jobs:
uses: actions/checkout@v5
- name: Setup Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml
index abc51eb91b..1d6944cc66 100644
--- a/.github/workflows/pylint-checks.yml
+++ b/.github/workflows/pylint-checks.yml
@@ -2,6 +2,7 @@ name: Pylint Checks
on:
pull_request:
+ merge_group:
push:
branches:
- master
@@ -37,7 +38,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install libxmlsec1-dev
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: 3.11
diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml
index 3f4cbeeb4d..b35846f37f 100644
--- a/.github/workflows/quality-checks.yml
+++ b/.github/workflows/quality-checks.yml
@@ -2,6 +2,7 @@ name: Quality checks
on:
pull_request:
+ merge_group:
push:
branches:
- master
@@ -30,12 +31,12 @@ jobs:
run: sudo apt-get update && sudo apt-get install libxmlsec1-dev
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml
index 520cd23a67..24bf5ed1c6 100644
--- a/.github/workflows/semgrep.yml
+++ b/.github/workflows/semgrep.yml
@@ -7,6 +7,7 @@ name: Semgrep code quality
on:
pull_request:
+ merge_group:
push:
branches:
- master
@@ -26,7 +27,7 @@ jobs:
with:
fetch-depth: 1
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@v6
with:
python-version: "${{ matrix.python-version }}"
diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml
index 2e5b04bcc2..ad3d6c3a1e 100644
--- a/.github/workflows/shellcheck.yml
+++ b/.github/workflows/shellcheck.yml
@@ -7,6 +7,7 @@ name: ShellCheck
on:
pull_request:
+ merge_group:
push:
branches:
- master
diff --git a/.github/workflows/static-assets-check.yml b/.github/workflows/static-assets-check.yml
index 43cb597c16..f604eba1c5 100644
--- a/.github/workflows/static-assets-check.yml
+++ b/.github/workflows/static-assets-check.yml
@@ -2,6 +2,7 @@ name: static assets check for lms and cms
on:
pull_request:
+ merge_group:
push:
branches:
- master
@@ -38,7 +39,7 @@ jobs:
uses: actions/checkout@v5
- name: Setup Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
@@ -48,7 +49,7 @@ jobs:
sudo apt-get install libxmlsec1-dev pkg-config
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json
index 827366365f..cb9beeb3c6 100644
--- a/.github/workflows/unit-test-shards.json
+++ b/.github/workflows/unit-test-shards.json
@@ -239,7 +239,6 @@
"cms/djangoapps/course_creators/",
"cms/djangoapps/export_course_metadata/",
"cms/djangoapps/modulestore_migrator/",
- "cms/djangoapps/maintenance/",
"cms/djangoapps/models/",
"cms/djangoapps/pipeline_js/",
"cms/djangoapps/xblock_config/",
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index d8b8c26cd0..7fba6b6b4c 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -1,7 +1,10 @@
name: unit-tests
+permissions:
+ contents: read
on:
pull_request:
+ merge_group:
push:
branches:
- master
@@ -22,7 +25,6 @@ jobs:
- "3.11"
django-version:
- "pinned"
- - "5.2"
# When updating the shards, remember to make the same changes in
# .github/workflows/unit-tests-gh-hosted.yml
shard_name:
@@ -88,7 +90,7 @@ jobs:
mongodb-version: ${{ matrix.mongo-version }}
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
@@ -124,26 +126,26 @@ jobs:
shell: bash
run: |
cd test_root/log
- mv pytest_warnings.json pytest_warnings_${{ matrix.shard_name }}.json
+ mv pytest_warnings.json pytest_warnings_${{ matrix.shard_name }}_${{ matrix.python-version }}_${{ matrix.django-version }}_${{ matrix.mongo-version }}_${{ matrix.os-version }}.json
- name: save pytest warnings json file
if: success()
uses: actions/upload-artifact@v4
with:
- name: pytest-warnings-json-${{ matrix.shard_name }}
+ name: pytest-warnings-json-${{ matrix.shard_name }}-${{ matrix.python-version }}-${{ matrix.django-version }}-${{ matrix.mongo-version }}-${{ matrix.os-version }}
path: |
test_root/log/pytest_warnings*.json
overwrite: true
- name: Renaming coverage data file
run: |
- mv reports/.coverage reports/${{ matrix.shard_name }}.coverage
+ mv reports/.coverage reports/${{ matrix.shard_name }}_${{ matrix.python-version }}_${{ matrix.django-version }}_${{ matrix.mongo-version }}_${{ matrix.os-version }}.coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
- name: coverage-${{ matrix.shard_name }}
- path: reports/${{ matrix.shard_name }}.coverage
+ name: coverage-${{ matrix.shard_name }}-${{ matrix.python-version }}-${{ matrix.django-version }}-${{ matrix.mongo-version }}-${{ matrix.os-version }}
+ path: reports/${{ matrix.shard_name }}_${{ matrix.python-version }}_${{ matrix.django-version }}_${{ matrix.mongo-version }}_${{ matrix.os-version }}.coverage
overwrite: true
collect-and-verify:
@@ -151,7 +153,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: 3.11
@@ -281,7 +283,7 @@ jobs:
uses: actions/checkout@v5
- name: Setup Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/units-test-scripts-structures-pruning.yml b/.github/workflows/units-test-scripts-structures-pruning.yml
index 14a01b5923..9ebc069ac9 100644
--- a/.github/workflows/units-test-scripts-structures-pruning.yml
+++ b/.github/workflows/units-test-scripts-structures-pruning.yml
@@ -2,6 +2,7 @@ name: units-test-scripts-common
on:
pull_request:
+ merge_group:
push:
branches:
- master
@@ -20,7 +21,7 @@ jobs:
uses: actions/checkout@v5
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/units-test-scripts-user-retirement.yml b/.github/workflows/units-test-scripts-user-retirement.yml
index 889c43a64a..2b28be5fe3 100644
--- a/.github/workflows/units-test-scripts-user-retirement.yml
+++ b/.github/workflows/units-test-scripts-user-retirement.yml
@@ -2,6 +2,7 @@ name: units-test-scripts-user-retirement
on:
pull_request:
+ merge_group:
push:
branches:
- master
@@ -20,7 +21,7 @@ jobs:
uses: actions/checkout@v5
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/upgrade-one-python-dependency.yml b/.github/workflows/upgrade-one-python-dependency.yml
index 3f9678593c..4b4bdccaf4 100644
--- a/.github/workflows/upgrade-one-python-dependency.yml
+++ b/.github/workflows/upgrade-one-python-dependency.yml
@@ -37,7 +37,7 @@ jobs:
ref: "${{ inputs.branch }}"
- name: Set up Python environment
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: "3.11"
diff --git a/.github/workflows/verify-dunder-init.yml b/.github/workflows/verify-dunder-init.yml
index c3248def2f..2a80d40ce5 100644
--- a/.github/workflows/verify-dunder-init.yml
+++ b/.github/workflows/verify-dunder-init.yml
@@ -4,6 +4,7 @@ on:
pull_request:
branches:
- master
+ merge_group:
jobs:
verify_dunder_init:
diff --git a/cms/djangoapps/contentstore/admin.py b/cms/djangoapps/contentstore/admin.py
index 67bb39b7a3..ac11ea42d0 100644
--- a/cms/djangoapps/contentstore/admin.py
+++ b/cms/djangoapps/contentstore/admin.py
@@ -100,6 +100,7 @@ class ComponentLinkAdmin(admin.ModelAdmin):
"upstream_context_key",
"downstream_usage_key",
"downstream_context_key",
+ "top_level_parent",
"version_synced",
"version_declined",
"created",
@@ -139,6 +140,7 @@ class ContainerLinkAdmin(admin.ModelAdmin):
"upstream_context_key",
"downstream_usage_key",
"downstream_context_key",
+ "top_level_parent",
"version_synced",
"version_declined",
"created",
diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py
index 84598fbcd9..87a40304fa 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py
@@ -177,6 +177,22 @@ class ContainerChildrenSerializer(serializers.Serializer):
Serializer for representing a vertical container with state and children.
"""
+ class UpstreamReadyToSyncChildrenInfoSerializer(serializers.Serializer):
+ """
+ Serializer used for the `upstream_ready_to_sync_children_info` field
+ """
+ id = serializers.CharField()
+ name = serializers.CharField()
+ upstream = serializers.CharField()
+ block_type = serializers.CharField()
+ is_modified = serializers.BooleanField()
+
children = ContainerChildSerializer(many=True)
is_published = serializers.BooleanField()
can_paste_component = serializers.BooleanField()
+ display_name = serializers.CharField()
+ upstream_ready_to_sync_children_info = UpstreamReadyToSyncChildrenInfoSerializer(
+ many=True,
+ required=False,
+ help_text="List of dictionaries describing upstream child components readiness to sync."
+ )
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py
index f79892658d..f392c47a67 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py
@@ -8,6 +8,7 @@ from opaque_keys.edx.keys import CourseKey
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
+from rest_framework.fields import BooleanField
from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES
from cms.djangoapps.contentstore.rest_api.v1.mixins import ContainerHandlerMixin
@@ -129,6 +130,11 @@ class ContainerChildrenView(APIView, ContainerHandlerMixin):
apidocs.ParameterLocation.PATH,
description="Container usage key",
),
+ apidocs.string_parameter(
+ "get_upstream_info",
+ apidocs.ParameterLocation.QUERY,
+ description="Gets the info of all ready to sync children",
+ ),
],
responses={
200: ContainerChildrenSerializer,
@@ -210,6 +216,7 @@ class ContainerChildrenView(APIView, ContainerHandlerMixin):
"version_available": 49,
"error_message": null,
"ready_to_sync": true,
+ "is_ready_to_sync_individually": true,
},
"actions": {
"can_copy": true,
@@ -230,11 +237,20 @@ class ContainerChildrenView(APIView, ContainerHandlerMixin):
],
"is_published": false,
"can_paste_component": true,
+ "display_name": "Vertical block 1"
+ "upstream_ready_to_sync_children_info": [{
+ "name": "Text",
+ "upstream": "lb:org:mylib:html:abcd",
+ 'block_type': "html",
+ 'is_modified': true,
+ 'id': "block-v1:org+101+101+type@html+block@3e3fa1f88adb4a108cd14e9002143690",
+ }]
}
```
"""
usage_key = self.get_object(usage_key_string)
current_xblock = get_xblock(usage_key, request.user)
+ get_upstream_info = BooleanField().to_internal_value(request.GET.get("get_upstream_info", False))
is_course = current_xblock.scope_ids.usage_id.context_key.is_course
with modulestore().bulk_operations(usage_key.course_key):
@@ -273,10 +289,18 @@ class ContainerChildrenView(APIView, ContainerHandlerMixin):
except ItemNotFoundError:
logging.error('Could not find any changes for block [%s]', usage_key)
+ upstream_ready_to_sync_children_info = []
+ if current_xblock.upstream and get_upstream_info:
+ upstream_link = UpstreamLink.get_for_block(current_xblock)
+ upstream_link_data = upstream_link.to_json(include_child_info=True)
+ upstream_ready_to_sync_children_info = upstream_link_data["ready_to_sync_children"]
+
container_data = {
"children": children,
"is_published": is_published,
"can_paste_component": is_course,
+ "upstream_ready_to_sync_children_info": upstream_ready_to_sync_children_info,
+ "display_name": current_xblock.display_name_with_default,
}
serializer = ContainerChildrenSerializer(container_data)
return Response(serializer.data)
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py
index 16e6679833..22f0cd0d54 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py
@@ -11,6 +11,7 @@ from xblock.validation import ValidationMessage
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from openedx.core.djangoapps.content_tagging.toggles import DISABLE_TAGGING_FEATURE
+from openedx.core.djangoapps.content_libraries.tests import ContentLibrariesRestApiTest
from xmodule.partitions.partitions import (
ENROLLMENT_TRACK_PARTITION_ID,
Group,
@@ -27,7 +28,7 @@ from xmodule.modulestore import (
) # lint-amnesty, pylint: disable=wrong-import-order
-class BaseXBlockContainer(CourseTestCase):
+class BaseXBlockContainer(CourseTestCase, ContentLibrariesRestApiTest):
"""
Base xBlock container handler.
@@ -48,6 +49,20 @@ class BaseXBlockContainer(CourseTestCase):
This method creates XBlock objects representing a course structure with chapters,
sequentials, verticals and others.
"""
+ self.lib = self._create_library(
+ slug="containers",
+ title="Container Test Library",
+ description="Units and more",
+ )
+ self.unit = self._create_container(self.lib["id"], "unit", display_name="Unit", slug=None)
+ self.html_block = self._add_block_to_library(self.lib["id"], "html", "Html1", can_stand_alone=False)
+ self._set_library_block_olx(
+ self.html_block["id"],
+ 'updated content upstream 1'
+ )
+ # Set version of html to 2
+ self._publish_library_block(self.html_block["id"])
+
self.chapter = self.create_block(
parent=self.course.location,
category="chapter",
@@ -60,7 +75,13 @@ class BaseXBlockContainer(CourseTestCase):
display_name="Lesson 1",
)
- self.vertical = self.create_block(self.sequential.location, "vertical", "Unit")
+ self.vertical = self.create_block(
+ self.sequential.location,
+ "vertical",
+ "Unit",
+ upstream=self.unit["id"],
+ upstream_version=1,
+ )
self.html_unit_first = self.create_block(
parent=self.vertical.location,
@@ -72,8 +93,8 @@ class BaseXBlockContainer(CourseTestCase):
parent=self.vertical.location,
category="html",
display_name="Html Content 2",
- upstream="lb:FakeOrg:FakeLib:html:FakeBlock",
- upstream_version=5,
+ upstream=self.html_block["id"],
+ upstream_version=1,
)
def create_block(self, parent, category, display_name, **kwargs):
@@ -204,9 +225,32 @@ class ContainerVerticalViewTest(BaseXBlockContainer):
url = self.get_reverse_url(self.vertical.location)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(len(response.data["children"]), 2)
- self.assertFalse(response.data["is_published"])
- self.assertTrue(response.data["can_paste_component"])
+ data = response.json()
+ self.assertEqual(len(data["children"]), 2)
+ self.assertFalse(data["is_published"])
+ self.assertTrue(data["can_paste_component"])
+ self.assertEqual(data["display_name"], "Unit")
+ self.assertEqual(data["upstream_ready_to_sync_children_info"], [])
+
+ def test_success_response_with_upstream_info(self):
+ """
+ Check that endpoint returns valid response data using `get_upstream_info` query param
+ """
+ url = self.get_reverse_url(self.vertical.location)
+ response = self.client.get(f"{url}?get_upstream_info=true")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ data = response.json()
+ self.assertEqual(len(data["children"]), 2)
+ self.assertFalse(data["is_published"])
+ self.assertTrue(data["can_paste_component"])
+ self.assertEqual(data["display_name"], "Unit")
+ self.assertEqual(data["upstream_ready_to_sync_children_info"], [{
+ "id": str(self.html_unit_second.usage_key),
+ "upstream": self.html_block["id"],
+ "block_type": "html",
+ "is_modified": False,
+ "name": "Html Content 2",
+ }])
def test_xblock_is_published(self):
"""
@@ -273,12 +317,12 @@ class ContainerVerticalViewTest(BaseXBlockContainer):
"can_manage_tags": True,
},
"upstream_link": {
- "upstream_ref": "lb:FakeOrg:FakeLib:html:FakeBlock",
- "version_synced": 5,
- "version_available": None,
+ "upstream_ref": self.html_block["id"],
+ "version_synced": 1,
+ "version_available": 2,
"version_declined": None,
- "error_message": "Linked upstream library block was not found in the system",
- "ready_to_sync": False,
+ "error_message": None,
+ "ready_to_sync": True,
"has_top_level_parent": False,
"is_modified": False,
},
diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py
index e5cd063e81..78730fe6a0 100644
--- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py
+++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py
@@ -11,11 +11,10 @@ from opaque_keys.edx.keys import UsageKey
from freezegun import freeze_time
from openedx.core.djangoapps.content_libraries.tests import ContentLibrariesRestApiTest
-from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_dict
+from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_string
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
-from xmodule.xml_block import serialize_field
@ddt.ddt
@@ -296,9 +295,9 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
parent_usage_key=str(self.course_subsection.usage_key),
upstream_key=self.upstream_unit["id"],
)
- downstream_unit_block_key = serialize_field(get_block_key_dict(
+ downstream_unit_block_key = get_block_key_string(
UsageKey.from_string(downstream_unit["locator"]),
- )).replace('"', '"')
+ )
status = self._get_sync_status(downstream_unit["locator"])
self.assertDictContainsEntries(status, {
'upstream_ref': self.upstream_unit["id"], # e.g. 'lct:CL-TEST:testlib:unit:u1'
@@ -898,9 +897,9 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
parent_usage_key=str(self.course_subsection.usage_key),
upstream_key=self.upstream_unit["id"],
)
- downstream_unit_block_key = serialize_field(get_block_key_dict(
+ downstream_unit_block_key = get_block_key_string(
UsageKey.from_string(downstream_unit["locator"]),
- )).replace('"', '"')
+ )
status = self._get_sync_status(downstream_unit["locator"])
self.assertDictContainsEntries(status, {
'upstream_ref': self.upstream_unit["id"], # e.g. 'lct:CL-TEST:testlib:unit:u1'
@@ -1259,9 +1258,9 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
parent_usage_key=str(self.course_subsection.usage_key),
upstream_key=self.upstream_unit["id"],
)
- downstream_unit_block_key = serialize_field(get_block_key_dict(
+ downstream_unit_block_key = get_block_key_string(
UsageKey.from_string(downstream_unit["locator"]),
- )).replace('"', '"')
+ )
children_downstream_keys = self._get_course_block_children(downstream_unit["locator"])
downstream_problem1 = children_downstream_keys[1]
assert "type@problem" in downstream_problem1
diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py
index b8f2c8f410..cf3838ac37 100644
--- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py
+++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py
@@ -16,7 +16,7 @@ from organizations.models import Organization
from cms.djangoapps.contentstore.helpers import StaticFileNotices
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from cms.djangoapps.contentstore.xblock_storage_handlers import view_handlers as xblock_view_handlers
-from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_dict
+from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_string
from cms.lib.xblock.upstream_sync import BadUpstream, UpstreamLink
from common.djangoapps.student.auth import add_users
from common.djangoapps.student.roles import CourseStaffRole
@@ -157,7 +157,7 @@ class _BaseDownstreamViewTestMixin:
parent=self.top_level_downstream_unit,
upstream=self.html_lib_id_2,
upstream_version=1,
- top_level_downstream_parent_key=get_block_key_dict(
+ top_level_downstream_parent_key=get_block_key_string(
self.top_level_downstream_unit.usage_key,
)
).usage_key
@@ -171,7 +171,7 @@ class _BaseDownstreamViewTestMixin:
parent=self.top_level_downstream_chapter,
upstream=self.top_level_subsection_id,
upstream_version=1,
- top_level_downstream_parent_key=get_block_key_dict(
+ top_level_downstream_parent_key=get_block_key_string(
self.top_level_downstream_chapter.usage_key,
),
)
@@ -180,7 +180,7 @@ class _BaseDownstreamViewTestMixin:
parent=self.top_level_downstream_sequential,
upstream=self.top_level_unit_id_2,
upstream_version=1,
- top_level_downstream_parent_key=get_block_key_dict(
+ top_level_downstream_parent_key=get_block_key_string(
self.top_level_downstream_chapter.usage_key,
),
)
@@ -189,7 +189,7 @@ class _BaseDownstreamViewTestMixin:
parent=self.top_level_downstream_unit_2,
upstream=self.video_lib_id_2,
upstream_version=1,
- top_level_downstream_parent_key=get_block_key_dict(
+ top_level_downstream_parent_key=get_block_key_string(
self.top_level_downstream_chapter.usage_key,
)
).usage_key
@@ -455,17 +455,14 @@ class DeleteDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
unit = modulestore().get_item(self.top_level_downstream_unit_2.usage_key)
# The sequential is the top-level parent for the unit
- assert unit.top_level_downstream_parent_key == {
- "id": str(self.top_level_downstream_sequential.usage_key.block_id),
- "type": str(self.top_level_downstream_sequential.usage_key.block_type),
- }
+ sequential_block_key = get_block_key_string(
+ self.top_level_downstream_sequential.usage_key
+ )
+ assert unit.top_level_downstream_parent_key == sequential_block_key
video = modulestore().get_item(self.top_level_downstream_video_key)
# The sequential is the top-level parent for the video
- assert video.top_level_downstream_parent_key == {
- "id": str(self.top_level_downstream_sequential.usage_key.block_id),
- "type": str(self.top_level_downstream_sequential.usage_key.block_type),
- }
+ assert video.top_level_downstream_parent_key == sequential_block_key
all_downstreams = self.client.get(
"/api/contentstore/v2/downstreams/",
@@ -646,6 +643,8 @@ class GetUpstreamViewTest(
self.assertDictEqual(data['ready_to_sync_children'][0], {
'name': html_block.display_name,
'upstream': str(self.html_lib_id_2),
+ 'block_type': 'html',
+ 'is_modified': False,
'id': str(html_block.usage_key),
})
@@ -1249,8 +1248,6 @@ class GetUpstreamViewTest(
'downstream_is_modified': False,
},
]
- print(data["results"])
- print(expected)
self.assertListEqual(data["results"], expected)
def test_200_get_ready_to_sync_top_level_parents_with_containers(self):
diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py
index 71b86acca2..91b49b8a37 100644
--- a/cms/djangoapps/contentstore/tasks.py
+++ b/cms/djangoapps/contentstore/tasks.py
@@ -92,6 +92,7 @@ from xmodule.modulestore.exceptions import DuplicateCourseError, InvalidProctori
from xmodule.modulestore.xml_exporter import export_course_to_xml, export_library_to_xml
from xmodule.modulestore.xml_importer import CourseImportException, import_course_from_xml, import_library_from_xml
from xmodule.tabs import StaticTab
+from xmodule.util.keys import BlockKey
from .models import ComponentLink, ContainerLink, LearningContextLinksStatus, LearningContextLinksStatusChoices
from .outlines import update_outline_from_modulestore
@@ -1649,10 +1650,11 @@ def handle_create_xblock_upstream_link(usage_key):
if not xblock.upstream or not xblock.upstream_version:
return
if xblock.top_level_downstream_parent_key is not None:
+ block_key = BlockKey.from_string(xblock.top_level_downstream_parent_key)
top_level_parent_usage_key = BlockUsageLocator(
xblock.course_id,
- xblock.top_level_downstream_parent_key.get('type'),
- xblock.top_level_downstream_parent_key.get('id'),
+ block_key.type,
+ block_key.id,
)
try:
ContainerLink.get_by_downstream_usage_key(top_level_parent_usage_key)
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index 08d858c550..4e79ba7099 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -501,7 +501,11 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin):
course = modulestore().get_course(self.course.id)
self.assertEqual(response.status_code, 200)
self.assertFalse(course.entrance_exam_enabled)
- self.assertEqual(course.entrance_exam_minimum_score_pct, None)
+ entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT)
+ if entrance_exam_minimum_score_pct.is_integer():
+ entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100
+
+ self.assertEqual(course.entrance_exam_minimum_score_pct, entrance_exam_minimum_score_pct)
self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id),
msg='The entrance exam should not be required anymore')
diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py
index c287f8c4db..55ec0e4ff5 100644
--- a/cms/djangoapps/contentstore/toggles.py
+++ b/cms/djangoapps/contentstore/toggles.py
@@ -86,25 +86,6 @@ def exam_setting_view_enabled(course_key):
return not LEGACY_STUDIO_EXAM_SETTINGS.is_enabled(course_key)
-# .. toggle_name: legacy_studio.text_editor
-# .. toggle_implementation: WaffleFlag
-# .. toggle_default: False
-# .. toggle_description: Temporarily fall back to the old Text component (a.k.a. html block) editor.
-# .. toggle_use_cases: temporary
-# .. toggle_creation_date: 2025-03-14
-# .. toggle_target_removal_date: 2025-09-14
-# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275
-# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available.
-LEGACY_STUDIO_TEXT_EDITOR = CourseWaffleFlag("legacy_studio.text_editor", __name__)
-
-
-def use_new_text_editor(course_key):
- """
- Returns a boolean = true if new text editor is enabled
- """
- return not LEGACY_STUDIO_TEXT_EDITOR.is_enabled(course_key)
-
-
# .. toggle_name: legacy_studio.video_editor
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 3ca1d20bf5..efd6905745 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -53,11 +53,9 @@ from cms.djangoapps.contentstore.toggles import (
use_new_home_page,
use_new_import_page,
use_new_schedule_details_page,
- use_new_text_editor,
use_new_textbooks_page,
use_new_unit_page,
use_new_updates_page,
- use_new_video_editor,
use_new_video_uploads_page,
)
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
@@ -117,6 +115,7 @@ from xmodule.partitions.partitions_service import (
get_all_partitions_for_course, # lint-amnesty, pylint: disable=wrong-import-order
)
from xmodule.services import ConfigurationService, SettingsService, TeamsConfigurationService
+from xmodule.util.keys import BlockKey
from .models import ComponentLink, ContainerLink
@@ -288,11 +287,10 @@ def get_editor_page_base_url(course_locator) -> str:
Gets course authoring microfrontend URL for links to the new base editors
"""
editor_url = None
- if use_new_text_editor(course_locator) or use_new_video_editor(course_locator):
- mfe_base_url = get_course_authoring_url(course_locator)
- course_mfe_url = f'{mfe_base_url}/course/{course_locator}/editor'
- if mfe_base_url:
- editor_url = course_mfe_url
+ mfe_base_url = get_course_authoring_url(course_locator)
+ course_mfe_url = f'{mfe_base_url}/course/{course_locator}/editor'
+ if mfe_base_url:
+ editor_url = course_mfe_url
return editor_url
@@ -2411,10 +2409,11 @@ def _create_or_update_component_link(created: datetime | None, xblock):
top_level_parent_usage_key = None
if xblock.top_level_downstream_parent_key is not None:
+ block_key = BlockKey.from_string(xblock.top_level_downstream_parent_key)
top_level_parent_usage_key = BlockUsageLocator(
xblock.usage_key.course_key,
- xblock.top_level_downstream_parent_key.get('type'),
- xblock.top_level_downstream_parent_key.get('id'),
+ block_key.type,
+ block_key.id,
)
ComponentLink.update_or_create(
@@ -2444,10 +2443,11 @@ def _create_or_update_container_link(created: datetime | None, xblock):
top_level_parent_usage_key = None
if xblock.top_level_downstream_parent_key is not None:
+ block_key = BlockKey.from_string(xblock.top_level_downstream_parent_key)
top_level_parent_usage_key = BlockUsageLocator(
xblock.usage_key.course_key,
- xblock.top_level_downstream_parent_key.get('type'),
- xblock.top_level_downstream_parent_key.get('id'),
+ block_key.type,
+ block_key.id,
)
ContainerLink.update_or_create(
diff --git a/cms/djangoapps/contentstore/views/entrance_exam.py b/cms/djangoapps/contentstore/views/entrance_exam.py
index bbefb0e9e8..5d914366bd 100644
--- a/cms/djangoapps/contentstore/views/entrance_exam.py
+++ b/cms/djangoapps/contentstore/views/entrance_exam.py
@@ -224,7 +224,7 @@ def _delete_entrance_exam(request, course_key):
if course.entrance_exam_id:
metadata = {
'entrance_exam_enabled': False,
- 'entrance_exam_minimum_score_pct': None,
+ 'entrance_exam_minimum_score_pct': _get_default_entrance_exam_minimum_pct(),
'entrance_exam_id': None,
}
CourseMetadata.update_from_dict(metadata, course, request.user)
diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py
index 01aff3d613..2b21a9b9b9 100644
--- a/cms/djangoapps/contentstore/views/tests/test_block.py
+++ b/cms/djangoapps/contentstore/views/tests/test_block.py
@@ -86,6 +86,7 @@ from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import (
add_container_page_publishing_info,
create_xblock_info,
)
+from common.test.utils import assert_dict_contains_subset
class AsideTest(XBlockAside):
@@ -863,7 +864,8 @@ class TestDuplicateItem(ItemTest, DuplicateHelper, OpenEdxEventsTestMixin):
XBLOCK_DUPLICATED.connect(event_receiver)
usage_key = self._duplicate_and_verify(self.vert_usage_key, self.seq_usage_key)
event_receiver.assert_called()
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
"signal": XBLOCK_DUPLICATED,
"sender": None,
diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
index ae7492ddbc..78c3935323 100644
--- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
+++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
@@ -33,7 +33,7 @@ from opaque_keys.edx.locator import LibraryUsageLocator, LibraryUsageLocatorV2
from pytz import UTC
from xblock.core import XBlock
from xblock.fields import Scope
-from .xblock_helpers import get_block_key_dict
+from .xblock_helpers import get_block_key_string
from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG
from cms.djangoapps.contentstore.helpers import StaticFileNotices
@@ -602,7 +602,7 @@ def sync_library_content(
block_id=f"{block_type}{uuid4().hex[:8]}",
fields={
"upstream": upstream_key,
- "top_level_downstream_parent_key": get_block_key_dict(
+ "top_level_downstream_parent_key": get_block_key_string(
top_level_downstream_parent.usage_key,
),
},
diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/xblock_helpers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/xblock_helpers.py
index 322bf530ab..82ed7297d5 100644
--- a/cms/djangoapps/contentstore/xblock_storage_handlers/xblock_helpers.py
+++ b/cms/djangoapps/contentstore/xblock_storage_handlers/xblock_helpers.py
@@ -18,11 +18,11 @@ def usage_key_with_run(usage_key_string: str) -> UsageKey:
return usage_key
-def get_block_key_dict(usage_key: UsageKey) -> dict:
+def get_block_key_string(usage_key: UsageKey) -> str:
"""
- Converts the usage_key in a dict with the form: `{"type": block_type, "id": block_id}`
+ Extract block key from UsageKey in string format: `html:my-id`.
"""
- return BlockKey.from_usage_key(usage_key)._asdict()
+ return str(BlockKey.from_usage_key(usage_key))
def get_tags_count(xblock: XBlock, include_children=False) -> dict[str, int]:
diff --git a/cms/djangoapps/maintenance/__init__.py b/cms/djangoapps/maintenance/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/cms/djangoapps/maintenance/tests.py b/cms/djangoapps/maintenance/tests.py
deleted file mode 100644
index 51f776b00a..0000000000
--- a/cms/djangoapps/maintenance/tests.py
+++ /dev/null
@@ -1,194 +0,0 @@
-"""
-Tests for the maintenance app views.
-"""
-
-
-import ddt
-from django.conf import settings
-from django.urls import reverse
-
-from common.djangoapps.student.tests.factories import AdminFactory, UserFactory
-from openedx.features.announcements.models import Announcement
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
-
-from .views import MAINTENANCE_VIEWS
-
-# This list contains URLs of all maintenance app views.
-MAINTENANCE_URLS = [reverse(view['url']) for view in MAINTENANCE_VIEWS.values()]
-
-
-class TestMaintenanceIndex(ModuleStoreTestCase):
- """
- Tests for maintenance index view.
- """
-
- def setUp(self):
- super().setUp()
- self.user = AdminFactory()
- login_success = self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
- self.assertTrue(login_success)
- self.view_url = reverse('maintenance:maintenance_index')
-
- def test_maintenance_index(self):
- """
- Test that maintenance index view lists all the maintenance app views.
- """
- response = self.client.get(self.view_url)
- self.assertContains(response, 'Maintenance', status_code=200)
-
- # Check that all the expected links appear on the index page.
- for url in MAINTENANCE_URLS:
- self.assertContains(response, url, status_code=200)
-
-
-@ddt.ddt
-class MaintenanceViewTestCase(ModuleStoreTestCase):
- """
- Base class for maintenance view tests.
- """
- view_url = ''
-
- def setUp(self):
- super().setUp()
- self.user = AdminFactory()
- login_success = self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
- self.assertTrue(login_success)
-
- def verify_error_message(self, data, error_message):
- """
- Verify the response contains error message.
- """
- response = self.client.post(self.view_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
- self.assertContains(response, error_message, status_code=200)
-
- def tearDown(self):
- """
- Reverse the setup.
- """
- self.client.logout()
- super().tearDown()
-
-
-@ddt.ddt
-class MaintenanceViewAccessTests(MaintenanceViewTestCase):
- """
- Tests for access control of maintenance views.
- """
- @ddt.data(*MAINTENANCE_URLS)
- def test_require_login(self, url):
- """
- Test that maintenance app requires user login.
- """
- # Log out then try to retrieve the page
- self.client.logout()
- response = self.client.get(url)
-
- # Expect a redirect to the login page
- redirect_url = '{login_url}?next={original_url}'.format(
- login_url=settings.LOGIN_URL,
- original_url=url,
- )
-
- # Studio login redirects to LMS login
- self.assertRedirects(response, redirect_url, target_status_code=302)
-
- @ddt.data(*MAINTENANCE_URLS)
- def test_global_staff_access(self, url):
- """
- Test that all maintenance app views are accessible to global staff user.
- """
- response = self.client.get(url)
- self.assertEqual(response.status_code, 200)
-
- @ddt.data(*MAINTENANCE_URLS)
- def test_non_global_staff_access(self, url):
- """
- Test that all maintenance app views are not accessible to non-global-staff user.
- """
- user = UserFactory(username='test', email='test@example.com', password=self.TEST_PASSWORD)
- login_success = self.client.login(username=user.username, password=self.TEST_PASSWORD)
- self.assertTrue(login_success)
-
- response = self.client.get(url)
- self.assertContains(
- response,
- f'Must be {settings.PLATFORM_NAME} staff to perform this action.',
- status_code=403
- )
-
-
-@ddt.ddt
-class TestAnnouncementsViews(MaintenanceViewTestCase):
- """
- Tests for the announcements edit view.
- """
-
- def setUp(self):
- super().setUp()
- self.admin = AdminFactory.create(
- email='staff@edx.org',
- username='admin',
- password=self.TEST_PASSWORD
- )
- self.client.login(username=self.admin.username, password=self.TEST_PASSWORD)
- self.non_staff_user = UserFactory.create(
- email='test@edx.org',
- username='test',
- password=self.TEST_PASSWORD
- )
-
- def test_index(self):
- """
- Test create announcement view
- """
- url = reverse("maintenance:announcement_index")
- response = self.client.get(url)
- self.assertContains(response, '
')
-
- def test_create(self):
- """
- Test create announcement view
- """
- url = reverse("maintenance:announcement_create")
- self.client.post(url, {"content": "Test Create Announcement", "active": True})
- result = Announcement.objects.filter(content="Test Create Announcement").exists()
- self.assertTrue(result)
-
- def test_edit(self):
- """
- Test edit announcement view
- """
- announcement = Announcement.objects.create(content="test")
- announcement.save()
- url = reverse("maintenance:announcement_edit", kwargs={"pk": announcement.pk})
- response = self.client.get(url)
- self.assertContains(response, '
diff --git a/cms/templates/maintenance/_announcement_delete.html b/cms/templates/maintenance/_announcement_delete.html
deleted file mode 100644
index 0397ef5a0b..0000000000
--- a/cms/templates/maintenance/_announcement_delete.html
+++ /dev/null
@@ -1,40 +0,0 @@
-<%page expression_filter="h"/>
-<%inherit file="base.html" />
-<%namespace name='static' file='../static_content.html'/>
-<%!
-from django.utils.translation import gettext as _
-from openedx.core.djangolib.markup import HTML, Text
-%>
-<%block name="title">${_('Delete Announcement')}%block>
-<%block name="viewtitle">
-
- ${_('Delete Announcement')}
-
-%block>
-
-<%block name="viewcontent">
-
-%block>
diff --git a/cms/templates/maintenance/_announcement_edit.html b/cms/templates/maintenance/_announcement_edit.html
deleted file mode 100644
index a9bee1c6fc..0000000000
--- a/cms/templates/maintenance/_announcement_edit.html
+++ /dev/null
@@ -1,50 +0,0 @@
-<%page expression_filter="h"/>
-<%inherit file="base.html" />
-<%namespace name='static' file='../static_content.html'/>
-<%!
-from django.utils.translation import gettext as _
-from openedx.core.djangolib.markup import HTML, Text
-%>
-<%block name="title">${_('Edit Announcement')}%block>
-<%block name="viewtitle">
-
- ${_('Edit Announcement')}
-
-%block>
-
-<%block name="viewcontent">
-
-%block>
-
-<%block name="header_extras">
-
-
-%block>
diff --git a/cms/templates/maintenance/_announcement_index.html b/cms/templates/maintenance/_announcement_index.html
deleted file mode 100644
index 68713c9986..0000000000
--- a/cms/templates/maintenance/_announcement_index.html
+++ /dev/null
@@ -1,59 +0,0 @@
-<%page expression_filter="h"/>
-<%namespace name='static' file='../static_content.html'/>
-<%!
-from django.urls import reverse
-from django.utils.translation import gettext as _
-
-from openedx.core.djangolib.markup import HTML, Text
-
-%>
-
diff --git a/cms/templates/maintenance/base.html b/cms/templates/maintenance/base.html
deleted file mode 100644
index 6979797a62..0000000000
--- a/cms/templates/maintenance/base.html
+++ /dev/null
@@ -1,21 +0,0 @@
-<%page expression_filter="h"/>
-<%inherit file="../base.html" />
-<%def name='online_help_token()'><% return 'maintenance' %>%def>
-<%namespace name='static' file='../static_content.html'/>
-<%!
-from django.urls import reverse
-from django.utils.translation import gettext as _
-%>
-<%block name="content">
-
-
-
- <%block name="viewtitle">
- %block>
-
-<%block name="viewcontent">%block>
-%block>
diff --git a/cms/templates/maintenance/container.html b/cms/templates/maintenance/container.html
deleted file mode 100644
index 319a57bfe9..0000000000
--- a/cms/templates/maintenance/container.html
+++ /dev/null
@@ -1,25 +0,0 @@
-<%page expression_filter="h"/>
-<%inherit file="base.html" />
-<%namespace name='static' file='../static_content.html'/>
-<%!
-from django.urls import reverse
-from openedx.core.djangolib.js_utils import js_escaped_string
-%>
-<%block name="title">${view['name']}%block>
-<%block name="viewtitle">
-
- ${view['name']}
-
-%block>
-
-<%block name="viewcontent">
-
- <%include file="_${view['slug']}.html"/>
-
-%block>
-
-<%block name="requirejs">
- require(["js/maintenance/${view['slug'] | n, js_escaped_string}"], function(MaintenanceFactory) {
- MaintenanceFactory("${reverse(view['url']) | n, js_escaped_string}");
- });
-%block>
diff --git a/cms/templates/maintenance/index.html b/cms/templates/maintenance/index.html
deleted file mode 100644
index 293cb90b4a..0000000000
--- a/cms/templates/maintenance/index.html
+++ /dev/null
@@ -1,20 +0,0 @@
-<%page expression_filter="h"/>
-<%inherit file="base.html" />
-<%namespace name='static' file='../static_content.html'/>
-<%!
-from django.utils.translation import gettext as _
-from django.urls import reverse
-%>
-<%block name="title">${_('Maintenance Dashboard')}%block>
-<%block name="viewcontent">
-
-
- % for view in views.values():
- -
- ${view['name']}
- ${view['description']}
-
- % endfor
-
-
-%block>
diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html
index 4fb3c71803..b3bfc38936 100644
--- a/cms/templates/studio_xblock_wrapper.html
+++ b/cms/templates/studio_xblock_wrapper.html
@@ -7,12 +7,11 @@ from lms.lib.utils import is_unit
from openedx.core.djangolib.js_utils import (
dump_js_escaped_json, js_escaped_string
)
-from cms.djangoapps.contentstore.toggles import use_new_text_editor, use_new_problem_editor, use_new_video_editor, use_video_gallery_flow
+from cms.djangoapps.contentstore.toggles import use_new_problem_editor, use_new_video_editor, use_video_gallery_flow
from cms.lib.xblock.upstream_sync import UpstreamLink
from openedx.core.djangoapps.content_tagging.toggles import is_tagging_feature_disabled
%>
<%
-use_new_editor_text = use_new_text_editor(xblock.context_key)
use_new_editor_video = use_new_video_editor(xblock.context_key)
use_new_editor_problem = use_new_problem_editor(xblock.context_key)
use_new_video_gallery_flow = use_video_gallery_flow()
@@ -83,7 +82,6 @@ can_unlink = upstream_info.upstream_ref and not upstream_info.has_top_level_pare
is-collapsed
% endif
"
- use-new-editor-text = ${use_new_editor_text}
use-new-editor-video = ${use_new_editor_video}
use-new-editor-problem = ${use_new_editor_problem}
use-video-gallery-flow = ${use_new_video_gallery_flow}
diff --git a/cms/templates/widgets/html-edit.html b/cms/templates/widgets/html-edit.html
deleted file mode 100644
index 8074e0d583..0000000000
--- a/cms/templates/widgets/html-edit.html
+++ /dev/null
@@ -1,13 +0,0 @@
-<%! from django.utils.translation import gettext as _ %>
-
-
-
-
- % if editor == 'visual':
-
- % endif
-
-
-
-
-<%include file="metadata-edit.html" />
diff --git a/cms/templates/widgets/user_dropdown.html b/cms/templates/widgets/user_dropdown.html
index 0ec00257ff..3fc0934b0d 100644
--- a/cms/templates/widgets/user_dropdown.html
+++ b/cms/templates/widgets/user_dropdown.html
@@ -21,11 +21,6 @@
${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}
- % if GlobalStaff().has_user(user):
-
- ${_("Maintenance")}
-
- % endif
${_("Sign Out")}
diff --git a/cms/urls.py b/cms/urls.py
index c60b56c3bd..048339bc9f 100644
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -284,8 +284,6 @@ if settings.FEATURES.get('CERTIFICATES_HTML_VIEW'):
certificates_list_handler, name='certificates_list_handler')
]
-# Maintenance Dashboard
-urlpatterns.append(path('maintenance/', include('cms.djangoapps.maintenance.urls', namespace='maintenance')))
if settings.DEBUG:
try:
diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py
index b5d46949b5..834c230871 100644
--- a/common/djangoapps/student/helpers.py
+++ b/common/djangoapps/student/helpers.py
@@ -589,7 +589,7 @@ def _cert_info(user, enrollment, cert_status):
linkedin_config = LinkedInAddToProfileConfiguration.current()
if linkedin_config.is_enabled():
status_dict['linked_in_url'] = linkedin_config.add_to_profile_url(
- course_overview.display_name, cert_status.get('mode'), cert_status['download_url'],
+ course_overview, cert_status.get('mode'), cert_status['download_url'],
)
if status in {'generating', 'downloadable', 'notpassing', 'restricted', 'auditing', 'unverified'}:
diff --git a/common/djangoapps/student/models/user.py b/common/djangoapps/student/models/user.py
index 6d16a5a95b..aa46de76c4 100644
--- a/common/djangoapps/student/models/user.py
+++ b/common/djangoapps/student/models/user.py
@@ -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
@@ -1375,21 +1375,37 @@ class LinkedInAddToProfileConfiguration(ConfigurationModel):
),
)
+ @property
+ def share_settings(self):
+ """
+ Initialize share_settings once for reuse across methods
+ """
+ if self._share_settings is None:
+ self._share_settings = configuration_helpers.get_value(
+ 'SOCIAL_SHARING_SETTINGS',
+ settings.SOCIAL_SHARING_SETTINGS
+ )
+ return self._share_settings
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._share_settings = None
+
def is_enabled(self, *key_fields): # pylint: disable=arguments-differ
"""
Checks both the model itself and share_settings to see if LinkedIn Add to Profile is enabled
"""
enabled = super().is_enabled(*key_fields)
- share_settings = configuration_helpers.get_value('SOCIAL_SHARING_SETTINGS', settings.SOCIAL_SHARING_SETTINGS)
- return share_settings.get('CERTIFICATE_LINKEDIN', enabled)
+ return self.share_settings.get('CERTIFICATE_LINKEDIN', enabled)
+
+ def add_to_profile_url(self, course, cert_mode, cert_url, certificate=None):
- def add_to_profile_url(self, course_name, cert_mode, cert_url, certificate=None):
"""
Construct the URL for the "add to profile" button. This will autofill the form based on
the params provided.
Arguments:
- course_name (str): The display name of the course.
+ course (CourseOverview): Course/CourseOverview Object.
cert_mode (str): The course mode of the user's certificate (e.g. "verified", "honor", "professional")
cert_url (str): The URL for the certificate.
@@ -1398,11 +1414,11 @@ class LinkedInAddToProfileConfiguration(ConfigurationModel):
If provided, this function will also autofill the certId and issue date for the cert.
"""
params = {
- 'name': self._cert_name(course_name, cert_mode),
+ 'name': self._cert_name(course.display_name, cert_mode),
'certUrl': cert_url,
}
- params.update(self._organization_information())
+ params.update(self._organization_information(course))
if certificate:
params.update({
@@ -1426,28 +1442,45 @@ class LinkedInAddToProfileConfiguration(ConfigurationModel):
Returns:
str: The formatted string to display for the name field on the LinkedIn Add to Profile dialog.
"""
- default_cert_name = self.MODE_TO_CERT_NAME.get(cert_mode, _('{platform_name} Certificate for {course_name}'))
+ default_cert_name = self.MODE_TO_CERT_NAME.get(
+ cert_mode, _('{platform_name} Certificate for {course_name}')
+ )
# Look for an override of the certificate name in the SOCIAL_SHARING_SETTINGS setting
- share_settings = configuration_helpers.get_value('SOCIAL_SHARING_SETTINGS', settings.SOCIAL_SHARING_SETTINGS)
- cert_name = share_settings.get('CERTIFICATE_LINKEDIN_MODE_TO_CERT_NAME', {}).get(cert_mode, default_cert_name)
+ cert_name = self.share_settings.get(
+ 'CERTIFICATE_LINKEDIN_MODE_TO_CERT_NAME', {}
+ ).get(cert_mode, default_cert_name)
return cert_name.format(
platform_name=configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME),
course_name=course_name
)
- def _organization_information(self):
+ def _organization_information(self, course=None):
"""
- Returns organization information for use in the URL parameters for add to profile.
+ Returns organization information for use in the URL parameters for add to
+ profile. By default when sharing to LinkedIn, Platform Name and/or Platform
+ LINKEDIN_COMPANY_ID will be used. If Course specific Organization Name is
+ prefered when sharing Certificate to linkedIn the flag for that
+ CERTIFICATE_LINKEDIN_DEFAULTS_TO_COURSE_ORGANIZATION_NAME should be set
+ to True alongside other LinkedIn settings
Returns:
- dict: Either the organization ID on LinkedIn or the organization's name
+ dict: Either the organization ID on LinkedIn, the organization's name or
+ organization name associated to a specific course
Will be used to prefill the organization on the add to profile action.
"""
- org_id = configuration_helpers.get_value('LINKEDIN_COMPANY_ID', self.company_identifier)
+ prefer_course_organization_name = self.share_settings.get(
+ 'CERTIFICATE_LINKEDIN_DEFAULTS_TO_COURSE_ORGANIZATION_NAME', False
+ )
+ if (prefer_course_organization_name and course):
+ return {"organizationName": course.display_organization}
+
+ org_id = configuration_helpers.get_value(
+ "LINKEDIN_COMPANY_ID", self.company_identifier
+ )
# Prefer organization ID per documentation at https://addtoprofile.linkedin.com/
if org_id:
- return {'organizationId': org_id}
+ return {"organizationId": org_id}
return {'organizationName': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)}
diff --git a/common/djangoapps/student/tests/test_events.py b/common/djangoapps/student/tests/test_events.py
index f336396e6e..7c9d625118 100644
--- a/common/djangoapps/student/tests/test_events.py
+++ b/common/djangoapps/student/tests/test_events.py
@@ -36,6 +36,7 @@ from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.modulestore.tests.django_utils import \
SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
+from common.test.utils import assert_dict_contains_subset
class TestUserProfileEvents(UserSettingsEventTestMixin, TestCase):
@@ -271,7 +272,8 @@ class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
enrollment = CourseEnrollment.enroll(self.user, self.course.id)
self.assertTrue(self.receiver_called)
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
"signal": COURSE_ENROLLMENT_CREATED,
"sender": None,
@@ -294,7 +296,7 @@ class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
creation_date=enrollment.created,
),
},
- event_receiver.call_args.kwargs
+ event_receiver.call_args.kwargs,
)
def test_enrollment_changed_event_emitted(self):
@@ -314,7 +316,8 @@ class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
enrollment.update_enrollment(mode="verified")
self.assertTrue(self.receiver_called)
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
"signal": COURSE_ENROLLMENT_CHANGED,
"sender": None,
@@ -337,7 +340,7 @@ class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
creation_date=enrollment.created,
),
},
- event_receiver.call_args.kwargs
+ event_receiver.call_args.kwargs,
)
def test_unenrollment_completed_event_emitted(self):
@@ -357,7 +360,8 @@ class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
CourseEnrollment.unenroll(self.user, self.course.id)
self.assertTrue(self.receiver_called)
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
"signal": COURSE_UNENROLLMENT_COMPLETED,
"sender": None,
@@ -380,7 +384,7 @@ class EnrollmentEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
creation_date=enrollment.created,
),
},
- event_receiver.call_args.kwargs
+ event_receiver.call_args.kwargs,
)
@@ -430,7 +434,8 @@ class TestCourseAccessRoleEvents(TestCase, OpenEdxEventsTestMixin):
role.add_users(self.user)
self.assertTrue(self.receiver_called)
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
"signal": COURSE_ACCESS_ROLE_ADDED,
"sender": None,
@@ -448,7 +453,7 @@ class TestCourseAccessRoleEvents(TestCase, OpenEdxEventsTestMixin):
role=role._role_name, # pylint: disable=protected-access
),
},
- event_receiver.call_args.kwargs
+ event_receiver.call_args.kwargs,
)
@ddt.data(
@@ -468,7 +473,8 @@ class TestCourseAccessRoleEvents(TestCase, OpenEdxEventsTestMixin):
role.remove_users(self.user)
self.assertTrue(self.receiver_called)
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
"signal": COURSE_ACCESS_ROLE_REMOVED,
"sender": None,
@@ -486,5 +492,5 @@ class TestCourseAccessRoleEvents(TestCase, OpenEdxEventsTestMixin):
role=role._role_name, # pylint: disable=protected-access
),
},
- event_receiver.call_args.kwargs
+ event_receiver.call_args.kwargs,
)
diff --git a/common/djangoapps/student/tests/test_linkedin.py b/common/djangoapps/student/tests/test_linkedin.py
index a5de21595b..87c2d404ae 100644
--- a/common/djangoapps/student/tests/test_linkedin.py
+++ b/common/djangoapps/student/tests/test_linkedin.py
@@ -1,9 +1,8 @@
"""Tests for LinkedIn Add to Profile configuration. """
-
+from types import SimpleNamespace
from urllib.parse import quote
import ddt
-
from django.conf import settings
from django.test import TestCase
@@ -17,6 +16,7 @@ class LinkedInAddToProfileUrlTests(TestCase):
COURSE_NAME = 'Test Course ☃'
CERT_URL = 'http://s3.edx/cert'
+ COURSE_ORGANIZATION = 'TEST+ORGANIZATION'
SITE_CONFIGURATION = {
'SOCIAL_SHARING_SETTINGS': {
'CERTIFICATE_LINKEDIN_MODE_TO_CERT_NAME': {
@@ -27,6 +27,17 @@ class LinkedInAddToProfileUrlTests(TestCase):
}
}
}
+ SITE_CONFIGURATION_COURSE_LEVEL_ORG = {
+ 'SOCIAL_SHARING_SETTINGS': {
+ 'CERTIFICATE_LINKEDIN_DEFAULTS_TO_COURSE_ORGANIZATION_NAME': True,
+ 'CERTIFICATE_LINKEDIN_MODE_TO_CERT_NAME': {
+ 'honor': '{platform_name} Honor Code Credential for {course_name}',
+ 'verified': '{platform_name} Verified Credential for {course_name}',
+ 'professional': '{platform_name} Professional Credential for {course_name}',
+ 'no-id-professional': '{platform_name} Professional Credential for {course_name}',
+ }
+ }
+ }
@ddt.data(
('honor', 'Honor+Code+Certificate+for+Test+Course+%E2%98%83'),
@@ -49,7 +60,13 @@ class LinkedInAddToProfileUrlTests(TestCase):
company_identifier=config.company_identifier,
)
- actual_url = config.add_to_profile_url(self.COURSE_NAME, cert_mode, self.CERT_URL)
+ course_mock_object = SimpleNamespace(
+ display_name=self.COURSE_NAME, display_organization=self.COURSE_ORGANIZATION
+ )
+
+ actual_url = config.add_to_profile_url(
+ course_mock_object, cert_mode, self.CERT_URL
+ )
self.assertEqual(actual_url, expected_url)
@@ -74,8 +91,49 @@ class LinkedInAddToProfileUrlTests(TestCase):
cert_url=quote(self.CERT_URL, safe=''),
company_identifier=config.company_identifier,
)
-
with with_site_configuration_context(configuration=self.SITE_CONFIGURATION):
- actual_url = config.add_to_profile_url(self.COURSE_NAME, cert_mode, self.CERT_URL)
+ course_mock_object = SimpleNamespace(
+ display_name=self.COURSE_NAME,
+ display_organization=self.COURSE_ORGANIZATION,
+ )
+ actual_url = config.add_to_profile_url(
+ course_mock_object, cert_mode, self.CERT_URL
+ )
+ self.assertEqual(actual_url, expected_url)
+
+ @ddt.data(
+ ('honor', 'Honor+Code+Credential+for+Test+Course+%E2%98%83'),
+ ('verified', 'Verified+Credential+for+Test+Course+%E2%98%83'),
+ ('professional', 'Professional+Credential+for+Test+Course+%E2%98%83'),
+ ('no-id-professional', 'Professional+Credential+for+Test+Course+%E2%98%83'),
+ ('default_mode', 'Certificate+for+Test+Course+%E2%98%83')
+ )
+ @ddt.unpack
+ def test_linked_in_url_with_course_org_name_override(
+ self, cert_mode, expected_cert_name
+ ):
+ config = LinkedInAddToProfileConfigurationFactory()
+
+ expected_url = (
+ 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&'
+ 'name={platform}+{cert_name}&certUrl={cert_url}&'
+ 'organizationName={course_organization_name}'
+ ).format(
+ platform=quote(settings.PLATFORM_NAME.encode('utf-8')),
+ cert_name=expected_cert_name,
+ cert_url=quote(self.CERT_URL, safe=''),
+ course_organization_name=quote(self.COURSE_ORGANIZATION.encode('utf-8')),
+ )
+
+ with with_site_configuration_context(
+ configuration=self.SITE_CONFIGURATION_COURSE_LEVEL_ORG
+ ):
+ course_mock_object = SimpleNamespace(
+ display_name=self.COURSE_NAME,
+ display_organization=self.COURSE_ORGANIZATION,
+ )
+ actual_url = config.add_to_profile_url(
+ course_mock_object, cert_mode, self.CERT_URL
+ )
self.assertEqual(actual_url, expected_url)
diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py
index c2e6d3b2aa..e8e8c58f40 100644
--- a/common/djangoapps/student/tests/tests.py
+++ b/common/djangoapps/student/tests/tests.py
@@ -51,8 +51,6 @@ from openedx.features.course_experience.url_helpers import make_learning_mfe_cou
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order
-
-
log = logging.getLogger(__name__)
BETA_TESTER_METHOD = 'common.djangoapps.student.helpers.access.is_beta_tester'
@@ -426,6 +424,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase):
self.course.start = datetime.now(pytz.UTC) - timedelta(days=2)
self.course.end = datetime.now(pytz.UTC) - timedelta(days=1)
self.course.display_name = 'Omega'
+ self.course.course_organization = 'Omega Org'
self.course = self.update_course(self.course, self.user.id)
cert = GeneratedCertificateFactory.create(
@@ -449,7 +448,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase):
).format(
platform=quote(settings.PLATFORM_NAME.encode('utf-8')),
cert_url=quote(cert.download_url, safe=''),
- company_identifier=linkedin_config.company_identifier
+ company_identifier=linkedin_config.company_identifier,
)
# Single assertion for the expected LinkedIn URL
diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py
index caddd325ba..18059ac687 100644
--- a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py
+++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py
@@ -30,6 +30,7 @@ from openedx.core.djangoapps.user_authn.views.login import login_user
from openedx.features.enterprise_support.tests.factories import EnterpriseCustomerFactory
from .base import IntegrationTestMixin
+from common.test.utils import assert_dict_contains_subset
TESTSHIB_ENTITY_ID = "https://idp.testshib.org/idp/shibboleth"
TESTSHIB_METADATA_URL = "https://mock.testshib.org/metadata/testshib-providers.xml"
@@ -402,8 +403,10 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin
assert msg.startswith("SAML login %s")
assert action_type == "request"
assert idp_name == self.PROVIDER_IDP_SLUG
- self.assertDictContainsSubset(
- {"idp": idp_name, "auth_entry": "login", "next": expected_next_url}, request_data
+ assert_dict_contains_subset(
+ self,
+ {"idp": idp_name, "auth_entry": "login", "next": expected_next_url},
+ request_data,
)
assert next_url == expected_next_url
assert "
This is a test thread body with some text."}
- )
- serialized = self.serialize(thread_data)
- assert serialized['preview_body'] == "This is a test thread body with some text."
-
-
-@ddt.ddt
-class CommentSerializerTest(SerializerTestMixin, SharedModuleStoreTestCase):
- """Tests for CommentSerializer."""
-
- def setUp(self):
- super().setUp()
- self.endorser = UserFactory.create()
- self.endorsed_at = "2015-05-18T12:34:56Z"
-
- def make_cs_content(self, overrides=None, with_endorsement=False):
- """
- Create a comment with the given overrides, plus some useful test data.
- """
- merged_overrides = {
- "user_id": str(self.author.id),
- "username": self.author.username
- }
- if with_endorsement:
- merged_overrides["endorsement"] = {
- "user_id": str(self.endorser.id),
- "time": self.endorsed_at
- }
- merged_overrides.update(overrides or {})
- return make_minimal_cs_comment(merged_overrides)
-
- def serialize(self, comment, thread_data=None):
- """
- Create a serializer with an appropriate context and use it to serialize
- the given comment, returning the result.
- """
- context = get_context(self.course, self.request, make_minimal_cs_thread(thread_data))
- return CommentSerializer(comment, context=context).data
-
- def test_basic(self):
- comment = {
- "type": "comment",
- "id": "test_comment",
- "thread_id": "test_thread",
- "user_id": str(self.author.id),
- "username": self.author.username,
- "anonymous": False,
- "anonymous_to_peers": False,
- "created_at": "2015-04-28T00:00:00Z",
- "updated_at": "2015-04-28T11:11:11Z",
- "body": "Test body",
- "endorsed": False,
- "abuse_flaggers": [],
- "votes": {"up_count": 4},
- "children": [],
- "child_count": 0,
- }
- expected = {
- "anonymous": False,
- "anonymous_to_peers": False,
- "id": "test_comment",
- "thread_id": "test_thread",
- "parent_id": None,
- "author": self.author.username,
- "author_label": None,
- "created_at": "2015-04-28T00:00:00Z",
- "updated_at": "2015-04-28T11:11:11Z",
- "raw_body": "Test body",
- "rendered_body": "Test body
",
- "endorsed": False,
- "endorsed_by": None,
- "endorsed_by_label": None,
- "endorsed_at": None,
- "abuse_flagged": False,
- "abuse_flagged_any_user": None,
- "voted": False,
- "vote_count": 4,
- "children": [],
- "editable_fields": ["abuse_flagged", "voted"],
- "child_count": 0,
- "can_delete": False,
- "last_edit": None,
- "edit_by_label": None,
- "profile_image": {
- "has_image": False,
- "image_url_full": "http://testserver/static/default_500.png",
- "image_url_large": "http://testserver/static/default_120.png",
- "image_url_medium": "http://testserver/static/default_50.png",
- "image_url_small": "http://testserver/static/default_30.png",
- },
- }
-
- assert self.serialize(comment) == expected
-
- @ddt.data(
- *itertools.product(
- [
- FORUM_ROLE_ADMINISTRATOR,
- FORUM_ROLE_MODERATOR,
- FORUM_ROLE_COMMUNITY_TA,
- FORUM_ROLE_STUDENT,
- ],
- [True, False]
- )
- )
- @ddt.unpack
- def test_endorsed_by(self, endorser_role_name, thread_anonymous):
- """
- Test correctness of the endorsed_by field.
-
- The endorser should be anonymous iff the thread is anonymous to the
- requester, and the endorser is not a privileged user.
-
- endorser_role_name is the name of the endorser's role.
- thread_anonymous is the value of the anonymous field in the thread.
- """
- self.create_role(endorser_role_name, [self.endorser])
- serialized = self.serialize(
- self.make_cs_content(with_endorsement=True),
- thread_data={"anonymous": thread_anonymous}
- )
- actual_endorser_anonymous = serialized["endorsed_by"] is None
- expected_endorser_anonymous = endorser_role_name == FORUM_ROLE_STUDENT and thread_anonymous
- assert actual_endorser_anonymous == expected_endorser_anonymous
-
- @ddt.data(
- (FORUM_ROLE_ADMINISTRATOR, "Moderator"),
- (FORUM_ROLE_MODERATOR, "Moderator"),
- (FORUM_ROLE_COMMUNITY_TA, "Community TA"),
- (FORUM_ROLE_STUDENT, None),
- )
- @ddt.unpack
- def test_endorsed_by_labels(self, role_name, expected_label):
- """
- Test correctness of the endorsed_by_label field.
-
- The label should be "Staff", "Moderator", or "Community TA" for the
- Administrator, Moderator, and Community TA roles, respectively.
-
- role_name is the name of the author's role.
- expected_label is the expected value of the author_label field in the
- API output.
- """
- self.create_role(role_name, [self.endorser])
- serialized = self.serialize(self.make_cs_content(with_endorsement=True))
- assert serialized['endorsed_by_label'] == expected_label
-
- def test_endorsed_at(self):
- serialized = self.serialize(self.make_cs_content(with_endorsement=True))
- assert serialized['endorsed_at'] == self.endorsed_at
-
- def test_children(self):
- comment = self.make_cs_content({
- "id": "test_root",
- "children": [
- self.make_cs_content({
- "id": "test_child_1",
- "parent_id": "test_root",
- }),
- self.make_cs_content({
- "id": "test_child_2",
- "parent_id": "test_root",
- "children": [
- self.make_cs_content({
- "id": "test_grandchild",
- "parent_id": "test_child_2"
- })
- ],
- }),
- ],
- })
- serialized = self.serialize(comment)
- assert serialized['children'][0]['id'] == 'test_child_1'
- assert serialized['children'][0]['parent_id'] == 'test_root'
- assert serialized['children'][1]['id'] == 'test_child_2'
- assert serialized['children'][1]['parent_id'] == 'test_root'
- assert serialized['children'][1]['children'][0]['id'] == 'test_grandchild'
- assert serialized['children'][1]['children'][0]['parent_id'] == 'test_child_2'
-
-
-@ddt.ddt
-class ThreadSerializerDeserializationTest(
- ForumsEnableMixin,
- CommentsServiceMockMixin,
- UrlResetMixin,
- SharedModuleStoreTestCase
-):
- """Tests for ThreadSerializer deserialization."""
- @classmethod
- @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
- def setUpClass(cls):
- super().setUpClass()
- cls.course = CourseFactory.create()
-
- @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
- def setUp(self):
- super().setUp()
- httpretty.reset()
- httpretty.enable()
- self.addCleanup(httpretty.reset)
- self.addCleanup(httpretty.disable)
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
- self.user = UserFactory.create()
- self.register_get_user_response(self.user)
- self.request = RequestFactory().get("/dummy")
- self.request.user = self.user
- self.minimal_data = {
- "course_id": str(self.course.id),
- "topic_id": "test_topic",
- "type": "discussion",
- "title": "Test Title",
- "raw_body": "Test body",
- }
- self.existing_thread = Thread(**make_minimal_cs_thread({
- "id": "existing_thread",
- "course_id": str(self.course.id),
- "commentable_id": "original_topic",
- "thread_type": "discussion",
- "title": "Original Title",
- "body": "Original body",
- "user_id": str(self.user.id),
- "username": self.user.username,
- "read": "False",
- "endorsed": "False"
- }))
-
- def save_and_reserialize(self, data, instance=None):
- """
- Create a serializer with the given data and (if updating) instance,
- ensure that it is valid, save the result, and return the full thread
- data from the serializer.
- """
- serializer = ThreadSerializer(
- instance,
- data=data,
- partial=(instance is not None),
- context=get_context(self.course, self.request)
- )
- assert serializer.is_valid()
- serializer.save()
- return serializer.data
-
- def test_create_missing_field(self):
- for field in self.minimal_data:
- data = self.minimal_data.copy()
- data.pop(field)
- serializer = ThreadSerializer(data=data)
- assert not serializer.is_valid()
- assert serializer.errors == {field: ['This field is required.']}
-
- @ddt.data("", " ")
- def test_create_empty_string(self, value):
- data = self.minimal_data.copy()
- data.update({field: value for field in ["topic_id", "title", "raw_body"]})
- serializer = ThreadSerializer(data=data, context=get_context(self.course, self.request))
- assert not serializer.is_valid()
- assert serializer.errors == {
- field: ['This field may not be blank.'] for field in ['topic_id', 'title', 'raw_body']
- }
-
- def test_update_empty(self):
- self.register_put_thread_response(self.existing_thread.attributes)
- self.save_and_reserialize({}, self.existing_thread)
- assert parsed_body(httpretty.last_request()) == {
- 'course_id': [str(self.course.id)],
- 'commentable_id': ['original_topic'],
- 'thread_type': ['discussion'],
- 'title': ['Original Title'],
- 'body': ['Original body'],
- 'anonymous': ['False'],
- 'anonymous_to_peers': ['False'],
- 'closed': ['False'],
- 'pinned': ['False'],
- 'user_id': [str(self.user.id)],
- 'read': ['False']
- }
-
- @ddt.data(True, False)
- def test_update_all(self, read):
- self.register_put_thread_response(self.existing_thread.attributes)
- data = {
- "topic_id": "edited_topic",
- "type": "question",
- "title": "Edited Title",
- "raw_body": "Edited body",
- "read": read,
- }
- saved = self.save_and_reserialize(data, self.existing_thread)
- assert parsed_body(httpretty.last_request()) == {
- 'course_id': [str(self.course.id)],
- 'commentable_id': ['edited_topic'],
- 'thread_type': ['question'],
- 'title': ['Edited Title'],
- 'body': ['Edited body'],
- 'anonymous': ['False'],
- 'anonymous_to_peers': ['False'],
- 'closed': ['False'],
- 'pinned': ['False'],
- 'user_id': [str(self.user.id)],
- 'read': [str(read)],
- 'editing_user_id': [str(self.user.id)],
- }
- for key in data:
- assert saved[key] == data[key]
-
- def test_update_anonymous(self):
- """
- Test that serializer correctly deserializes the anonymous field when
- updating an existing thread.
- """
- self.register_put_thread_response(self.existing_thread.attributes)
- data = {
- "anonymous": True,
- }
- self.save_and_reserialize(data, self.existing_thread)
- assert parsed_body(httpretty.last_request())["anonymous"] == ['True']
-
- def test_update_anonymous_to_peers(self):
- """
- Test that serializer correctly deserializes the anonymous_to_peers
- field when updating an existing thread.
- """
- self.register_put_thread_response(self.existing_thread.attributes)
- data = {
- "anonymous_to_peers": True,
- }
- self.save_and_reserialize(data, self.existing_thread)
- assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ['True']
-
- @ddt.data("", " ")
- def test_update_empty_string(self, value):
- serializer = ThreadSerializer(
- self.existing_thread,
- data={field: value for field in ["topic_id", "title", "raw_body"]},
- partial=True,
- context=get_context(self.course, self.request)
- )
- assert not serializer.is_valid()
- assert serializer.errors == {
- field: ['This field may not be blank.'] for field in ['topic_id', 'title', 'raw_body']
- }
-
- def test_update_course_id(self):
- serializer = ThreadSerializer(
- self.existing_thread,
- data={"course_id": "some/other/course"},
- partial=True,
- context=get_context(self.course, self.request)
- )
- assert not serializer.is_valid()
- assert serializer.errors == {'course_id': ['This field is not allowed in an update.']}
-
-
-@ddt.ddt
-class CommentSerializerDeserializationTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase):
- """Tests for ThreadSerializer deserialization."""
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.course = CourseFactory.create()
-
- def setUp(self):
- super().setUp()
- httpretty.reset()
- httpretty.enable()
- self.addCleanup(httpretty.reset)
- self.addCleanup(httpretty.disable)
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
- patcher = mock.patch(
- "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
- )
- self.mock_get_course_id_by_comment = patcher.start()
- self.addCleanup(patcher.stop)
- patcher = mock.patch(
- "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
- )
- self.mock_get_course_id_by_thread = patcher.start()
- self.addCleanup(patcher.stop)
- self.user = UserFactory.create()
- self.register_get_user_response(self.user)
- self.request = RequestFactory().get("/dummy")
- self.request.user = self.user
- self.minimal_data = {
- "thread_id": "test_thread",
- "raw_body": "Test body",
- }
- self.existing_comment = Comment(**make_minimal_cs_comment({
- "id": "existing_comment",
- "thread_id": "dummy",
- "body": "Original body",
- "user_id": str(self.user.id),
- "username": self.user.username,
- "course_id": str(self.course.id),
- }))
-
- def save_and_reserialize(self, data, instance=None):
- """
- Create a serializer with the given data, ensure that it is valid, save
- the result, and return the full comment data from the serializer.
- """
- context = get_context(
- self.course,
- self.request,
- make_minimal_cs_thread({"course_id": str(self.course.id)})
- )
- serializer = CommentSerializer(
- instance,
- data=data,
- partial=(instance is not None),
- context=context
- )
- assert serializer.is_valid()
- serializer.save()
- return serializer.data
-
- def test_create_missing_field(self):
- for field in self.minimal_data:
- data = self.minimal_data.copy()
- data.pop(field)
- serializer = CommentSerializer(
- data=data,
- context=get_context(self.course, self.request, make_minimal_cs_thread())
- )
- assert not serializer.is_valid()
- assert serializer.errors == {field: ['This field is required.']}
-
- def test_update_empty(self):
- self.register_put_comment_response(self.existing_comment.attributes)
- self.save_and_reserialize({}, instance=self.existing_comment)
- assert parsed_body(httpretty.last_request()) == {
- 'body': ['Original body'],
- 'course_id': [str(self.course.id)],
- 'user_id': [str(self.user.id)],
- 'anonymous': ['False'],
- 'anonymous_to_peers': ['False'],
- 'endorsed': ['False']
- }
-
- def test_update_anonymous(self):
- """
- Test that serializer correctly deserializes the anonymous field when
- updating an existing comment.
- """
- self.register_put_comment_response(self.existing_comment.attributes)
- data = {
- "anonymous": True,
- }
- self.save_and_reserialize(data, self.existing_comment)
- assert parsed_body(httpretty.last_request())["anonymous"] == ['True']
-
- def test_update_anonymous_to_peers(self):
- """
- Test that serializer correctly deserializes the anonymous_to_peers
- field when updating an existing comment.
- """
- self.register_put_comment_response(self.existing_comment.attributes)
- data = {
- "anonymous_to_peers": True,
- }
- self.save_and_reserialize(data, self.existing_comment)
- assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ['True']
-
- @ddt.data("thread_id", "parent_id")
- def test_update_non_updatable(self, field):
- serializer = CommentSerializer(
- self.existing_comment,
- data={field: "different_value"},
- partial=True,
- context=get_context(self.course, self.request)
- )
- assert not serializer.is_valid()
- assert serializer.errors == {field: ['This field is not allowed in an update.']}
-
-
-class FilterSpamTest(SharedModuleStoreTestCase):
- """
- Tests for the filter_spam method
- """
- @override_settings(DISCUSSION_SPAM_URLS=['example.com'])
- def test_filter(self):
- self.assertEqual(
- filter_spam_urls_from_html('')[0],
- 'abc
'
- )
- self.assertEqual(
- filter_spam_urls_from_html('example.com/abc/def
')[0],
- ''
- )
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py
index 06ab0c463b..563c5e80c8 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py
@@ -5,7 +5,6 @@ Tests for Discussion API serializers
import itertools
from unittest import mock
-from urllib.parse import urlparse
import ddt
import httpretty
@@ -23,7 +22,6 @@ from lms.djangoapps.discussion.rest_api.tests.utils import (
ForumMockUtilsMixin,
make_minimal_cs_comment,
make_minimal_cs_thread,
- parsed_body,
)
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment
@@ -58,12 +56,6 @@ class CommentSerializerDeserializationTest(ForumsEnableMixin, ForumMockUtilsMixi
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=True
- )
- patcher.start()
- self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
)
@@ -322,6 +314,76 @@ class CommentSerializerDeserializationTest(ForumsEnableMixin, ForumMockUtilsMixi
assert saved['endorsed_by_label'] is None
assert saved['endorsed_at'] is None
+ def test_create_missing_field(self):
+ for field in self.minimal_data:
+ data = self.minimal_data.copy()
+ data.pop(field)
+ serializer = CommentSerializer(
+ data=data,
+ context=get_context(self.course, self.request, make_minimal_cs_thread())
+ )
+ assert not serializer.is_valid()
+ assert serializer.errors == {field: ['This field is required.']}
+
+ def test_update_empty(self):
+ self.register_put_comment_response(self.existing_comment.attributes)
+ self.save_and_reserialize({}, instance=self.existing_comment)
+ parsed_body = {
+ 'body': 'Original body',
+ 'course_id': str(self.course.id),
+ 'user_id': str(self.user.id),
+ 'anonymous': False,
+ 'anonymous_to_peers': False,
+ 'endorsed': False,
+ 'comment_id': 'existing_comment',
+ }
+ self.check_mock_called("update_comment")
+ self.check_mock_called_with(
+ "update_comment",
+ -1,
+ **parsed_body
+ )
+
+ def test_update_anonymous(self):
+ """
+ Test that serializer correctly deserializes the anonymous field when
+ updating an existing comment.
+ """
+ self.register_put_comment_response(self.existing_comment.attributes)
+ data = {
+ "anonymous": True,
+ }
+ self.save_and_reserialize(data, self.existing_comment)
+ call_args = self.get_mock_func_calls("update_comment")[0]
+ args, kwargs = call_args
+ assert kwargs['anonymous']
+
+ def test_update_anonymous_to_peers(self):
+ """
+ Test that serializer correctly deserializes the anonymous_to_peers
+ field when updating an existing comment.
+ """
+ self.register_put_comment_response(self.existing_comment.attributes)
+ data = {
+ "anonymous_to_peers": True,
+ }
+ self.save_and_reserialize(data, self.existing_comment)
+
+ call_args = self.get_mock_func_calls("update_comment")[0]
+ args, kwargs = call_args
+ assert kwargs['anonymous_to_peers']
+
+ @ddt.data("thread_id", "parent_id")
+ def test_update_non_updatable(self, field):
+ serializer = CommentSerializer(
+ self.existing_comment,
+ data={field: "different_value"},
+ partial=True,
+ context=get_context(self.course, self.request)
+ )
+ assert not serializer.is_valid()
+ assert serializer.errors == {field: ['This field is not allowed in an update.']}
+
@ddt.ddt
class ThreadSerializerDeserializationTest(
@@ -351,12 +413,6 @@ class ThreadSerializerDeserializationTest(
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=True
- )
- patcher.start()
- self.addCleanup(patcher.stop)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/dummy")
@@ -476,3 +532,550 @@ class ThreadSerializerDeserializationTest(
call_args = self.get_mock_func_calls("create_thread")[0]
args, kwargs = call_args
assert kwargs['anonymous_to_peers']
+
+
+@ddt.ddt
+class SerializerTestMixin(ForumsEnableMixin, UrlResetMixin, ForumMockUtilsMixin):
+ """
+ Test Mixin for Serializer tests
+ """
+ @classmethod
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.course = CourseFactory.create()
+ super().setUpClassAndForumMock()
+
+ @classmethod
+ def tearDownClass(cls):
+ """Stop patches after tests complete."""
+ super().tearDownClass()
+ super().disposeForumMocks()
+
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def setUp(self):
+ super().setUp()
+ httpretty.reset()
+ httpretty.enable()
+ self.addCleanup(httpretty.reset)
+ self.addCleanup(httpretty.disable)
+ patcher = mock.patch(
+ "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
+ )
+ self.mock_get_course_id_by_comment = patcher.start()
+ self.addCleanup(patcher.stop)
+ patcher = mock.patch(
+ "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
+ )
+
+ self.mock_get_course_id_by_thread = patcher.start()
+ self.addCleanup(patcher.stop)
+ self.maxDiff = None # pylint: disable=invalid-name
+ self.user = UserFactory.create()
+ self.register_get_user_response(self.user)
+ self.request = RequestFactory().get("/dummy")
+ self.request.user = self.user
+ self.author = UserFactory.create()
+
+ def create_role(self, role_name, users, course=None):
+ """Create a Role in self.course with the given name and users"""
+ course = course or self.course
+ role = Role.objects.create(name=role_name, course_id=course.id)
+ role.users.set(users)
+
+ @ddt.data(
+ (FORUM_ROLE_ADMINISTRATOR, True, False, True),
+ (FORUM_ROLE_ADMINISTRATOR, False, True, False),
+ (FORUM_ROLE_MODERATOR, True, False, True),
+ (FORUM_ROLE_MODERATOR, False, True, False),
+ (FORUM_ROLE_COMMUNITY_TA, True, False, True),
+ (FORUM_ROLE_COMMUNITY_TA, False, True, False),
+ (FORUM_ROLE_STUDENT, True, False, True),
+ (FORUM_ROLE_STUDENT, False, True, True),
+ )
+ @ddt.unpack
+ def test_anonymity(self, role_name, anonymous, anonymous_to_peers, expected_serialized_anonymous):
+ """
+ Test that content is properly made anonymous.
+
+ Content should be anonymous if the anonymous field is true or the
+ anonymous_to_peers field is true and the requester does not have a
+ privileged role.
+
+ role_name is the name of the requester's role.
+ anonymous is the value of the anonymous field in the content.
+ anonymous_to_peers is the value of the anonymous_to_peers field in the
+ content.
+ expected_serialized_anonymous is whether the content should actually be
+ anonymous in the API output when requested by a user with the given
+ role.
+ """
+ self.register_get_user_response(self.user)
+ self.create_role(role_name, [self.user])
+ serialized = self.serialize(
+ self.make_cs_content({"anonymous": anonymous, "anonymous_to_peers": anonymous_to_peers})
+ )
+ actual_serialized_anonymous = serialized["author"] is None
+ assert actual_serialized_anonymous == expected_serialized_anonymous
+
+ @ddt.data(
+ (FORUM_ROLE_ADMINISTRATOR, False, "Moderator"),
+ (FORUM_ROLE_ADMINISTRATOR, True, None),
+ (FORUM_ROLE_MODERATOR, False, "Moderator"),
+ (FORUM_ROLE_MODERATOR, True, None),
+ (FORUM_ROLE_COMMUNITY_TA, False, "Community TA"),
+ (FORUM_ROLE_COMMUNITY_TA, True, None),
+ (FORUM_ROLE_STUDENT, False, None),
+ (FORUM_ROLE_STUDENT, True, None),
+ )
+ @ddt.unpack
+ def test_author_labels(self, role_name, anonymous, expected_label):
+ """
+ Test correctness of the author_label field.
+
+ The label should be "Staff", "Moderator", or "Community TA" for the
+ Administrator, Moderator, and Community TA roles, respectively, but
+ the label should not be present if the content is anonymous.
+
+ role_name is the name of the author's role.
+ anonymous is the value of the anonymous field in the content.
+ expected_label is the expected value of the author_label field in the
+ API output.
+ """
+ self.register_get_user_response(self.user)
+ self.create_role(role_name, [self.author])
+ serialized = self.serialize(self.make_cs_content({"anonymous": anonymous}))
+ assert serialized['author_label'] == expected_label
+
+ def test_abuse_flagged(self):
+ self.register_get_user_response(self.user)
+ serialized = self.serialize(self.make_cs_content({"abuse_flaggers": [str(self.user.id)]}))
+ assert serialized['abuse_flagged'] is True
+
+ def test_voted(self):
+ thread_id = "test_thread"
+ self.register_get_user_response(self.user, upvoted_ids=[thread_id])
+ serialized = self.serialize(self.make_cs_content({"id": thread_id}))
+ assert serialized['voted'] is True
+
+
+@ddt.ddt
+class CommentSerializerTest(SerializerTestMixin, SharedModuleStoreTestCase):
+ """Tests for CommentSerializer."""
+
+ def setUp(self):
+ super().setUp()
+ self.endorser = UserFactory.create()
+ self.endorsed_at = "2015-05-18T12:34:56Z"
+ super().setUpClassAndForumMock()
+
+ @classmethod
+ def tearDownClass(cls):
+ """Stop patches after tests complete."""
+ super().tearDownClass()
+ super().disposeForumMocks()
+
+ def make_cs_content(self, overrides=None, with_endorsement=False):
+ """
+ Create a comment with the given overrides, plus some useful test data.
+ """
+ merged_overrides = {
+ "user_id": str(self.author.id),
+ "username": self.author.username
+ }
+ if with_endorsement:
+ merged_overrides["endorsement"] = {
+ "user_id": str(self.endorser.id),
+ "time": self.endorsed_at
+ }
+ merged_overrides.update(overrides or {})
+ return make_minimal_cs_comment(merged_overrides)
+
+ def serialize(self, comment, thread_data=None):
+ """
+ Create a serializer with an appropriate context and use it to serialize
+ the given comment, returning the result.
+ """
+ context = get_context(self.course, self.request, make_minimal_cs_thread(thread_data))
+ return CommentSerializer(comment, context=context).data
+
+ def test_basic(self):
+ self.register_get_user_response(self.user)
+ comment = {
+ "type": "comment",
+ "id": "test_comment",
+ "thread_id": "test_thread",
+ "user_id": str(self.author.id),
+ "username": self.author.username,
+ "anonymous": False,
+ "anonymous_to_peers": False,
+ "created_at": "2015-04-28T00:00:00Z",
+ "updated_at": "2015-04-28T11:11:11Z",
+ "body": "Test body",
+ "endorsed": False,
+ "abuse_flaggers": [],
+ "votes": {"up_count": 4},
+ "children": [],
+ "child_count": 0,
+ }
+ expected = {
+ "anonymous": False,
+ "anonymous_to_peers": False,
+ "id": "test_comment",
+ "thread_id": "test_thread",
+ "parent_id": None,
+ "author": self.author.username,
+ "author_label": None,
+ "created_at": "2015-04-28T00:00:00Z",
+ "updated_at": "2015-04-28T11:11:11Z",
+ "raw_body": "Test body",
+ "rendered_body": "Test body
",
+ "endorsed": False,
+ "endorsed_by": None,
+ "endorsed_by_label": None,
+ "endorsed_at": None,
+ "abuse_flagged": False,
+ "abuse_flagged_any_user": None,
+ "voted": False,
+ "vote_count": 4,
+ "children": [],
+ "editable_fields": ["abuse_flagged", "voted"],
+ "child_count": 0,
+ "can_delete": False,
+ "last_edit": None,
+ "edit_by_label": None,
+ "profile_image": {
+ "has_image": False,
+ "image_url_full": "http://testserver/static/default_500.png",
+ "image_url_large": "http://testserver/static/default_120.png",
+ "image_url_medium": "http://testserver/static/default_50.png",
+ "image_url_small": "http://testserver/static/default_30.png",
+ },
+ }
+
+ assert self.serialize(comment) == expected
+
+ @ddt.data(
+ *itertools.product(
+ [
+ FORUM_ROLE_ADMINISTRATOR,
+ FORUM_ROLE_MODERATOR,
+ FORUM_ROLE_COMMUNITY_TA,
+ FORUM_ROLE_STUDENT,
+ ],
+ [True, False]
+ )
+ )
+ @ddt.unpack
+ def test_endorsed_by(self, endorser_role_name, thread_anonymous):
+ """
+ Test correctness of the endorsed_by field.
+
+ The endorser should be anonymous iff the thread is anonymous to the
+ requester, and the endorser is not a privileged user.
+
+ endorser_role_name is the name of the endorser's role.
+ thread_anonymous is the value of the anonymous field in the thread.
+ """
+ self.register_get_user_response(self.user)
+ self.create_role(endorser_role_name, [self.endorser])
+ serialized = self.serialize(
+ self.make_cs_content(with_endorsement=True),
+ thread_data={"anonymous": thread_anonymous}
+ )
+ actual_endorser_anonymous = serialized["endorsed_by"] is None
+ expected_endorser_anonymous = endorser_role_name == FORUM_ROLE_STUDENT and thread_anonymous
+ assert actual_endorser_anonymous == expected_endorser_anonymous
+
+ @ddt.data(
+ (FORUM_ROLE_ADMINISTRATOR, "Moderator"),
+ (FORUM_ROLE_MODERATOR, "Moderator"),
+ (FORUM_ROLE_COMMUNITY_TA, "Community TA"),
+ (FORUM_ROLE_STUDENT, None),
+ )
+ @ddt.unpack
+ def test_endorsed_by_labels(self, role_name, expected_label):
+ """
+ Test correctness of the endorsed_by_label field.
+
+ The label should be "Staff", "Moderator", or "Community TA" for the
+ Administrator, Moderator, and Community TA roles, respectively.
+
+ role_name is the name of the author's role.
+ expected_label is the expected value of the author_label field in the
+ API output.
+ """
+ self.register_get_user_response(self.user)
+ self.create_role(role_name, [self.endorser])
+ serialized = self.serialize(self.make_cs_content(with_endorsement=True))
+ assert serialized['endorsed_by_label'] == expected_label
+
+ def test_endorsed_at(self):
+ self.register_get_user_response(self.user)
+ serialized = self.serialize(self.make_cs_content(with_endorsement=True))
+ assert serialized['endorsed_at'] == self.endorsed_at
+
+ def test_children(self):
+ self.register_get_user_response(self.user)
+ comment = self.make_cs_content({
+ "id": "test_root",
+ "children": [
+ self.make_cs_content({
+ "id": "test_child_1",
+ "parent_id": "test_root",
+ }),
+ self.make_cs_content({
+ "id": "test_child_2",
+ "parent_id": "test_root",
+ "children": [
+ self.make_cs_content({
+ "id": "test_grandchild",
+ "parent_id": "test_child_2"
+ })
+ ],
+ }),
+ ],
+ })
+ serialized = self.serialize(comment)
+ assert serialized['children'][0]['id'] == 'test_child_1'
+ assert serialized['children'][0]['parent_id'] == 'test_root'
+ assert serialized['children'][1]['id'] == 'test_child_2'
+ assert serialized['children'][1]['parent_id'] == 'test_root'
+ assert serialized['children'][1]['children'][0]['id'] == 'test_grandchild'
+ assert serialized['children'][1]['children'][0]['parent_id'] == 'test_child_2'
+
+
+@ddt.ddt
+class ThreadSerializerSerializationTest(SerializerTestMixin, SharedModuleStoreTestCase, ForumMockUtilsMixin):
+ """Tests for ThreadSerializer serialization."""
+
+ def make_cs_content(self, overrides):
+ """
+ Create a thread with the given overrides, plus some useful test data.
+ """
+ merged_overrides = {
+ "course_id": str(self.course.id),
+ "user_id": str(self.author.id),
+ "username": self.author.username,
+ "read": True,
+ "endorsed": True,
+ "resp_total": 0,
+ }
+ merged_overrides.update(overrides)
+ return make_minimal_cs_thread(merged_overrides)
+
+ def serialize(self, thread):
+ """
+ Create a serializer with an appropriate context and use it to serialize
+ the given thread, returning the result.
+ """
+ return ThreadSerializer(thread, context=get_context(self.course, self.request)).data
+
+ def test_basic(self):
+ thread = make_minimal_cs_thread({
+ "id": "test_thread",
+ "course_id": str(self.course.id),
+ "commentable_id": "test_topic",
+ "user_id": str(self.author.id),
+ "username": self.author.username,
+ "title": "Test Title",
+ "body": "Test body",
+ "pinned": True,
+ "votes": {"up_count": 4},
+ "comments_count": 5,
+ "unread_comments_count": 3,
+ })
+ expected = self.expected_thread_data({
+ "author": self.author.username,
+ "can_delete": False,
+ "vote_count": 4,
+ "comment_count": 6,
+ "unread_comment_count": 3,
+ "pinned": True,
+ "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"],
+ "abuse_flagged_count": None,
+ "edit_by_label": None,
+ "closed_by_label": None,
+ })
+ assert self.serialize(thread) == expected
+
+ thread["thread_type"] = "question"
+ expected.update({
+ "type": "question",
+ "comment_list_url": None,
+ "endorsed_comment_list_url": (
+ "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=True"
+ ),
+ "non_endorsed_comment_list_url": (
+ "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=False"
+ ),
+ })
+ assert self.serialize(thread) == expected
+
+ def test_pinned_missing(self):
+ """
+ Make sure that older threads in the comments service without the pinned
+ field do not break serialization
+ """
+ thread_data = self.make_cs_content({})
+ del thread_data["pinned"]
+ self.register_get_thread_response(thread_data)
+ serialized = self.serialize(thread_data)
+ assert serialized['pinned'] is False
+
+ def test_group(self):
+ self.course.cohort_config = {"cohorted": True}
+ modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
+ cohort = CohortFactory.create(course_id=self.course.id)
+ serialized = self.serialize(self.make_cs_content({"group_id": cohort.id}))
+ assert serialized['group_id'] == cohort.id
+ assert serialized['group_name'] == cohort.name
+
+ def test_following(self):
+ thread_id = "test_thread"
+ self.register_get_user_response(self.user, subscribed_thread_ids=[thread_id])
+ serialized = self.serialize(self.make_cs_content({"id": thread_id}))
+ assert serialized['following'] is True
+
+ def test_response_count(self):
+ thread_data = self.make_cs_content({"resp_total": 2})
+ self.register_get_thread_response(thread_data)
+ serialized = self.serialize(thread_data)
+ assert serialized['response_count'] == 2
+
+ def test_response_count_missing(self):
+ thread_data = self.make_cs_content({})
+ del thread_data["resp_total"]
+ self.register_get_thread_response(thread_data)
+ serialized = self.serialize(thread_data)
+ assert 'response_count' not in serialized
+
+ @ddt.data(
+ (FORUM_ROLE_MODERATOR, True),
+ (FORUM_ROLE_STUDENT, False),
+ ("author", True),
+ )
+ @ddt.unpack
+ def test_closed_by_label_field(self, role, visible):
+ """
+ Tests if closed by field is visible to author and priviledged users
+ """
+ moderator = UserFactory()
+ request_role = FORUM_ROLE_STUDENT if role == "author" else role
+ author = self.user if role == "author" else self.author
+ self.create_role(FORUM_ROLE_MODERATOR, [moderator])
+ self.create_role(request_role, [self.user])
+
+ thread = make_minimal_cs_thread({
+ "id": "test_thread",
+ "course_id": str(self.course.id),
+ "commentable_id": "test_topic",
+ "user_id": str(author.id),
+ "username": author.username,
+ "title": "Test Title",
+ "body": "Test body",
+ "pinned": True,
+ "votes": {"up_count": 4},
+ "comments_count": 5,
+ "unread_comments_count": 3,
+ "closed_by": moderator
+ })
+ closed_by_label = "Moderator" if visible else None
+ closed_by = moderator if visible else None
+ can_delete = role != FORUM_ROLE_STUDENT
+ editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"]
+ if role == "author":
+ editable_fields.remove("voted")
+ editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type'])
+ elif role == FORUM_ROLE_MODERATOR:
+ editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned',
+ 'raw_body', 'title', 'topic_id', 'type'])
+ expected = self.expected_thread_data({
+ "author": author.username,
+ "can_delete": can_delete,
+ "vote_count": 4,
+ "comment_count": 6,
+ "unread_comment_count": 3,
+ "pinned": True,
+ "editable_fields": sorted(editable_fields),
+ "abuse_flagged_count": None,
+ "edit_by_label": None,
+ "closed_by_label": closed_by_label,
+ "closed_by": closed_by,
+ })
+ assert self.serialize(thread) == expected
+
+ @ddt.data(
+ (FORUM_ROLE_MODERATOR, True),
+ (FORUM_ROLE_STUDENT, False),
+ ("author", True),
+ )
+ @ddt.unpack
+ def test_edit_by_label_field(self, role, visible):
+ """
+ Tests if closed by field is visible to author and priviledged users
+ """
+ moderator = UserFactory()
+ request_role = FORUM_ROLE_STUDENT if role == "author" else role
+ author = self.user if role == "author" else self.author
+ self.create_role(FORUM_ROLE_MODERATOR, [moderator])
+ self.create_role(request_role, [self.user])
+
+ thread = make_minimal_cs_thread({
+ "id": "test_thread",
+ "course_id": str(self.course.id),
+ "commentable_id": "test_topic",
+ "user_id": str(author.id),
+ "username": author.username,
+ "title": "Test Title",
+ "body": "Test body",
+ "pinned": True,
+ "votes": {"up_count": 4},
+ "edit_history": [{"editor_username": moderator}],
+ "comments_count": 5,
+ "unread_comments_count": 3,
+ "closed_by": None
+ })
+ edit_by_label = "Moderator" if visible else None
+ can_delete = role != FORUM_ROLE_STUDENT
+ last_edit = None if role == FORUM_ROLE_STUDENT else {"editor_username": moderator}
+ editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"]
+
+ if role == "author":
+ editable_fields.remove("voted")
+ editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type'])
+
+ elif role == FORUM_ROLE_MODERATOR:
+ editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned',
+ 'raw_body', 'title', 'topic_id', 'type'])
+
+ expected = self.expected_thread_data({
+ "author": author.username,
+ "can_delete": can_delete,
+ "vote_count": 4,
+ "comment_count": 6,
+ "unread_comment_count": 3,
+ "pinned": True,
+ "editable_fields": sorted(editable_fields),
+ "abuse_flagged_count": None,
+ "last_edit": last_edit,
+ "edit_by_label": edit_by_label,
+ "closed_by_label": None,
+ "closed_by": None,
+ })
+ assert self.serialize(thread) == expected
+
+ def test_get_preview_body(self):
+ """
+ Test for the 'get_preview_body' method.
+
+ This test verifies that the 'get_preview_body' method returns a cleaned
+ version of the thread's body that is suitable for display as a preview.
+ The test specifically focuses on handling the presence of multiple
+ spaces within the body.
+ """
+ thread_data = self.make_cs_content(
+ {"body": "This is a test thread body with some text.
"}
+ )
+ serialized = self.serialize(thread_data)
+ assert serialized['preview_body'] == "This is a test thread body with some text."
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks_v2.py
index 153ba15604..09339558c4 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_tasks_v2.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks_v2.py
@@ -60,46 +60,19 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
self.course = CourseFactory.create()
- # Patch 1
- patcher1 = mock.patch(
- 'openedx.core.djangoapps.django_comment_common.comment_client.thread.is_forum_v2_enabled_for_thread',
- autospec=True
- )
- mock_forum_v2 = patcher1.start()
- mock_forum_v2.return_value = (True, str(self.course.id))
- self.addCleanup(patcher1.stop)
-
- # Patch 2
- patcher2 = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher2.start()
- self.addCleanup(patcher2.stop)
-
- # Patch 3
- patcher3 = mock.patch(
+ patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread",
return_value=self.course.id
)
- self.mock_get_course_id_by_thread = patcher3.start()
- self.addCleanup(patcher3.stop)
+ self.mock_get_course_id_by_thread = patcher.start()
+ self.addCleanup(patcher.stop)
- # Patch 4
- patcher4 = mock.patch(
+ patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment",
return_value=self.course.id
)
- self.mock_get_course_id_by_comment = patcher4.start()
- self.addCleanup(patcher4.stop)
-
- # Patch 5
- patcher5 = mock.patch(
- "openedx.core.djangoapps.django_comment_common.comment_client.models.is_forum_v2_enabled_for_comment",
- return_value=(True, str(self.course.id))
- )
- self.mock_is_forum_v2_enabled_for_comment = patcher5.start()
- self.addCleanup(patcher5.stop)
+ self.mock_get_course_id_by_comment = patcher.start()
+ self.addCleanup(patcher.stop)
self.user_1 = UserFactory.create()
CourseEnrollment.enroll(self.user_1, self.course.id)
@@ -410,20 +383,6 @@ class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCas
super().setUp()
httpretty.reset()
httpretty.enable()
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
-
- patcher = mock.patch(
- 'openedx.core.djangoapps.django_comment_common.comment_client.thread.is_forum_v2_enabled_for_thread',
- autospec=True
- )
- mock_forum_v2 = patcher.start()
- mock_forum_v2.return_value = (True, str(self.course.id))
- self.addCleanup(patcher.stop)
self.course = CourseFactory.create()
patcher = mock.patch(
@@ -439,13 +398,6 @@ class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCas
self.mock_get_course_id_by_comment = patcher.start()
self.addCleanup(patcher.stop)
- patcher = mock.patch(
- "openedx.core.djangoapps.django_comment_common.comment_client.models.is_forum_v2_enabled_for_comment",
- return_value=(True, str(self.course.id))
- )
- self.mock_is_forum_v2_enabled_for_comment = patcher.start()
- self.addCleanup(patcher.stop)
-
self.user_1 = UserFactory.create()
CourseEnrollment.enroll(self.user_1, self.course.id)
self.user_2 = UserFactory.create()
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py
index be8a793abc..18a180b43a 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_views.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py
@@ -2,311 +2,40 @@
Tests for Discussion API views
"""
-
import json
-import random
from datetime import datetime
from unittest import mock
-from urllib.parse import parse_qs, urlencode, urlparse
+from urllib.parse import urlencode
import ddt
-import httpretty
-from django.core.files.uploadedfile import SimpleUploadedFile
-from django.test import override_settings
from django.urls import reverse
-from edx_toggles.toggles.testutils import override_waffle_flag
-from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from rest_framework import status
-from rest_framework.test import APIClient, APITestCase
-from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
-from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string
-from xmodule.modulestore import ModuleStoreEnum
-from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
-from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory
-from common.djangoapps.course_modes.models import CourseMode
-from common.djangoapps.course_modes.tests.factories import CourseModeFactory
-from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff
from common.djangoapps.student.tests.factories import (
- AdminFactory,
CourseEnrollmentFactory,
- SuperuserFactory,
UserFactory
)
from common.djangoapps.util.testing import UrlResetMixin
from lms.djangoapps.discussion.django_comment_client.tests.utils import (
ForumsEnableMixin,
- config_course_discussions,
- topic_name_to_id,
)
from lms.djangoapps.discussion.rest_api.tests.utils import (
- CommentsServiceMockMixin,
+ ForumMockUtilsMixin,
make_minimal_cs_comment,
make_minimal_cs_thread,
)
-from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
-from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS
-from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider
-from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task
-from openedx.core.djangoapps.django_comment_common.models import (
- CourseDiscussionSettings,
- Role,
-)
-from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles
-from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
-from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory
-from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus
-
-
-class DiscussionAPIViewTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin):
- """
- Mixin for common code in tests of Discussion API views. This includes
- creation of common structures (e.g. a course, user, and enrollment), logging
- in the test client, utility functions, and a test case for unauthenticated
- requests. Subclasses must set self.url in their setUp methods.
- """
-
- client_class = APIClient
-
- @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
- def setUp(self):
- super().setUp()
- self.maxDiff = None # pylint: disable=invalid-name
- self.course = CourseFactory.create(
- org="x",
- course="y",
- run="z",
- start=datetime.now(UTC),
- discussion_topics={"Test Topic": {"id": "test_topic"}}
- )
- self.password = "Password1234"
- self.user = UserFactory.create(password=self.password)
- # Ensure that parental controls don't apply to this user
- self.user.profile.year_of_birth = 1970
- self.user.profile.save()
- CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
- self.client.login(username=self.user.username, password=self.password)
-
- def assert_response_correct(self, response, expected_status, expected_content):
- """
- Assert that the response has the given status code and parsed content
- """
- assert response.status_code == expected_status
- parsed_content = json.loads(response.content.decode('utf-8'))
- assert parsed_content == expected_content
-
- def register_thread(self, overrides=None):
- """
- Create cs_thread with minimal fields and register response
- """
- cs_thread = make_minimal_cs_thread({
- "id": "test_thread",
- "course_id": str(self.course.id),
- "commentable_id": "test_topic",
- "username": self.user.username,
- "user_id": str(self.user.id),
- "thread_type": "discussion",
- "title": "Test Title",
- "body": "Test body",
- })
- cs_thread.update(overrides or {})
- self.register_get_thread_response(cs_thread)
- self.register_put_thread_response(cs_thread)
-
- def register_comment(self, overrides=None):
- """
- Create cs_comment with minimal fields and register response
- """
- cs_comment = make_minimal_cs_comment({
- "id": "test_comment",
- "course_id": str(self.course.id),
- "thread_id": "test_thread",
- "username": self.user.username,
- "user_id": str(self.user.id),
- "body": "Original body",
- })
- cs_comment.update(overrides or {})
- self.register_get_comment_response(cs_comment)
- self.register_put_comment_response(cs_comment)
- self.register_post_comment_response(cs_comment, thread_id="test_thread")
-
- def test_not_authenticated(self):
- self.client.logout()
- response = self.client.get(self.url)
- self.assert_response_correct(
- response,
- 401,
- {"developer_message": "Authentication credentials were not provided."}
- )
-
- def test_inactive(self):
- self.user.is_active = False
- self.test_basic()
-
-
-@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
-class UploadFileViewTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestCase):
- """
- Tests for UploadFileView.
- """
-
- @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
- def setUp(self):
- super().setUp()
- self.valid_file = {
- "uploaded_file": SimpleUploadedFile(
- "test.jpg",
- b"test content",
- content_type="image/jpeg",
- ),
- }
- self.user = UserFactory.create(password=self.TEST_PASSWORD)
- self.course = CourseFactory.create(org='a', course='b', run='c', start=datetime.now(UTC))
- self.url = reverse("upload_file", kwargs={"course_id": str(self.course.id)})
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
-
- def user_login(self):
- """
- Authenticates the test client with the example user.
- """
- self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
-
- def enroll_user_in_course(self):
- """
- Makes the example user enrolled to the course.
- """
- CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
-
- def assert_upload_success(self, response):
- """
- Asserts that the upload response was successful and returned the
- expected contents.
- """
- assert response.status_code == status.HTTP_200_OK
- assert response.content_type == "application/json"
- response_data = json.loads(response.content)
- assert "location" in response_data
-
- def test_file_upload_by_unauthenticated_user(self):
- """
- Should fail if an unauthenticated user tries to upload a file.
- """
- response = self.client.post(self.url, self.valid_file)
- assert response.status_code == status.HTTP_401_UNAUTHORIZED
-
- def test_file_upload_by_unauthorized_user(self):
- """
- Should fail if the user is not either staff or a student
- enrolled in the course.
- """
- self.user_login()
- response = self.client.post(self.url, self.valid_file)
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
- def test_file_upload_by_enrolled_user(self):
- """
- Should succeed when a valid file is uploaded by an authenticated
- user who's enrolled in the course.
- """
- self.user_login()
- self.enroll_user_in_course()
- response = self.client.post(self.url, self.valid_file)
- self.assert_upload_success(response)
-
- def test_file_upload_by_global_staff(self):
- """
- Should succeed when a valid file is uploaded by a global staff
- member.
- """
- self.user_login()
- GlobalStaff().add_users(self.user)
- response = self.client.post(self.url, self.valid_file)
- self.assert_upload_success(response)
-
- def test_file_upload_by_instructor(self):
- """
- Should succeed when a valid file is uploaded by a course instructor.
- """
- self.user_login()
- CourseInstructorRole(course_key=self.course.id).add_users(self.user)
- response = self.client.post(self.url, self.valid_file)
- self.assert_upload_success(response)
-
- def test_file_upload_by_course_staff(self):
- """
- Should succeed when a valid file is uploaded by a course staff
- member.
- """
- self.user_login()
- CourseStaffRole(course_key=self.course.id).add_users(self.user)
- response = self.client.post(self.url, self.valid_file)
- self.assert_upload_success(response)
-
- def test_file_upload_with_thread_key(self):
- """
- Should contain the given thread_key in the uploaded file name.
- """
- self.user_login()
- self.enroll_user_in_course()
- response = self.client.post(self.url, {
- **self.valid_file,
- "thread_key": "somethread",
- })
- response_data = json.loads(response.content)
- assert "/somethread/" in response_data["location"]
-
- def test_file_upload_with_invalid_file(self):
- """
- Should fail if the uploaded file format is not allowed.
- """
- self.user_login()
- self.enroll_user_in_course()
- invalid_file = {
- "uploaded_file": SimpleUploadedFile(
- "test.txt",
- b"test content",
- content_type="text/plain",
- ),
- }
- response = self.client.post(self.url, invalid_file)
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
- def test_file_upload_with_invalid_course_id(self):
- """
- Should fail if the course does not exist.
- """
- self.user_login()
- self.enroll_user_in_course()
- url = reverse("upload_file", kwargs={"course_id": "d/e/f"})
- response = self.client.post(url, self.valid_file)
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
- def test_file_upload_with_no_data(self):
- """
- Should fail when the user sends a request missing an
- `uploaded_file` field.
- """
- self.user_login()
- self.enroll_user_in_course()
- response = self.client.post(self.url, data={})
- assert response.status_code == status.HTTP_400_BAD_REQUEST
@ddt.ddt
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
-@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_FORUM_V2": False})
class CommentViewSetListByUserTest(
ForumsEnableMixin,
- CommentsServiceMockMixin,
+ ForumMockUtilsMixin,
UrlResetMixin,
ModuleStoreTestCase,
):
@@ -314,21 +43,20 @@ class CommentViewSetListByUserTest(
Common test cases for views retrieving user-published content.
"""
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ super().setUpClassAndForumMock()
+
+ @classmethod
+ def tearDownClass(cls):
+ super().tearDownClass()
+ super().disposeForumMocks()
+
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
- httpretty.reset()
- httpretty.enable()
- self.addCleanup(httpretty.reset)
- self.addCleanup(httpretty.disable)
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
-
self.user = UserFactory.create(password=self.TEST_PASSWORD)
self.register_get_user_response(self.user)
@@ -498,1530 +226,3 @@ class CommentViewSetListByUserTest(
url = self.build_url(self.user.username, self.course.id, page=2)
response = self.client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
-
-
-@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
-@override_settings(DISCUSSION_MODERATION_EDIT_REASON_CODES={"test-edit-reason": "Test Edit Reason"})
-@override_settings(DISCUSSION_MODERATION_CLOSE_REASON_CODES={"test-close-reason": "Test Close Reason"})
-class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
- """Tests for CourseView"""
-
- def setUp(self):
- super().setUp()
- self.url = reverse("discussion_course", kwargs={"course_id": str(self.course.id)})
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
-
- def test_404(self):
- response = self.client.get(
- reverse("course_topics", kwargs={"course_id": "non/existent/course"})
- )
- self.assert_response_correct(
- response,
- 404,
- {"developer_message": "Course not found."}
- )
-
- def test_basic(self):
- response = self.client.get(self.url)
- self.assert_response_correct(
- response,
- 200,
- {
- "id": str(self.course.id),
- "is_posting_enabled": True,
- "blackouts": [],
- "thread_list_url": "http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz",
- "following_thread_list_url": (
- "http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=True"
- ),
- "topics_url": "http://testserver/api/discussion/v1/course_topics/course-v1:x+y+z",
- "enable_in_context": True,
- "group_at_subsection": False,
- "provider": "legacy",
- "allow_anonymous": True,
- "allow_anonymous_to_peers": False,
- "has_bulk_delete_privileges": False,
- "has_moderation_privileges": False,
- 'is_course_admin': False,
- 'is_course_staff': False,
- "is_group_ta": False,
- 'is_user_admin': False,
- "user_roles": ["Student"],
- "edit_reasons": [{"code": "test-edit-reason", "label": "Test Edit Reason"}],
- "post_close_reasons": [{"code": "test-close-reason", "label": "Test Close Reason"}],
- 'show_discussions': True,
- 'is_notify_all_learners_enabled': False,
- 'captcha_settings': {
- 'enabled': False,
- 'site_key': None,
- },
- "is_email_verified": True,
- "only_verified_users_can_post": False,
- "content_creation_rate_limited": False
- }
- )
-
-
-@httpretty.activate
-@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
-class RetireViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
- """Tests for CourseView"""
-
- def setUp(self):
- super().setUp()
- RetirementState.objects.create(state_name='PENDING', state_execution_order=1)
- self.retire_forums_state = RetirementState.objects.create(state_name='RETIRE_FORUMS', state_execution_order=11)
-
- self.retirement = UserRetirementStatus.create_retirement(self.user)
- self.retirement.current_state = self.retire_forums_state
- self.retirement.save()
-
- self.superuser = SuperuserFactory()
- self.superuser_client = APIClient()
- self.retired_username = get_retired_username_by_username(self.user.username)
- self.url = reverse("retire_discussion_user")
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
-
- def assert_response_correct(self, response, expected_status, expected_content):
- """
- Assert that the response has the given status code and content
- """
- assert response.status_code == expected_status
-
- if expected_content:
- assert response.content.decode('utf-8') == expected_content
-
- def build_jwt_headers(self, user):
- """
- Helper function for creating headers for the JWT authentication.
- """
- token = create_jwt_for_user(user)
- headers = {'HTTP_AUTHORIZATION': 'JWT ' + token}
- return headers
-
- def test_basic(self):
- """
- Check successful retirement case
- """
- self.register_get_user_retire_response(self.user)
- headers = self.build_jwt_headers(self.superuser)
- data = {'username': self.user.username}
- response = self.superuser_client.post(self.url, data, **headers)
- self.assert_response_correct(response, 204, b"")
-
- def test_downstream_forums_error(self):
- """
- Check that we bubble up errors from the comments service
- """
- self.register_get_user_retire_response(self.user, status=500, body="Server error")
- headers = self.build_jwt_headers(self.superuser)
- data = {'username': self.user.username}
- response = self.superuser_client.post(self.url, data, **headers)
- self.assert_response_correct(response, 500, '"Server error"')
-
- def test_nonexistent_user(self):
- """
- Check that we handle unknown users appropriately
- """
- nonexistent_username = "nonexistent user"
- self.retired_username = get_retired_username_by_username(nonexistent_username)
- data = {'username': nonexistent_username}
- headers = self.build_jwt_headers(self.superuser)
- response = self.superuser_client.post(self.url, data, **headers)
- self.assert_response_correct(response, 404, None)
-
- def test_not_authenticated(self):
- """
- Override the parent implementation of this, we JWT auth for this API
- """
- pass # lint-amnesty, pylint: disable=unnecessary-pass
-
-
-@ddt.ddt
-@httpretty.activate
-@mock.patch('django.conf.settings.USERNAME_REPLACEMENT_WORKER', 'test_replace_username_service_worker')
-@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
-class ReplaceUsernamesViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
- """Tests for ReplaceUsernamesView"""
-
- def setUp(self):
- super().setUp()
- self.worker = UserFactory()
- self.worker.username = "test_replace_username_service_worker"
- self.worker_client = APIClient()
- self.new_username = "test_username_replacement"
- self.url = reverse("replace_discussion_username")
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
-
- def assert_response_correct(self, response, expected_status, expected_content):
- """
- Assert that the response has the given status code and content
- """
- assert response.status_code == expected_status
-
- if expected_content:
- assert str(response.content) == expected_content
-
- def build_jwt_headers(self, user):
- """
- Helper function for creating headers for the JWT authentication.
- """
- token = create_jwt_for_user(user)
- headers = {'HTTP_AUTHORIZATION': 'JWT ' + token}
- return headers
-
- def call_api(self, user, client, data):
- """ Helper function to call API with data """
- data = json.dumps(data)
- headers = self.build_jwt_headers(user)
- return client.post(self.url, data, content_type='application/json', **headers)
-
- @ddt.data(
- [{}, {}],
- {},
- [{"test_key": "test_value", "test_key_2": "test_value_2"}]
- )
- def test_bad_schema(self, mapping_data):
- """ Verify the endpoint rejects bad data schema """
- data = {
- "username_mappings": mapping_data
- }
- response = self.call_api(self.worker, self.worker_client, data)
- assert response.status_code == 400
-
- def test_auth(self):
- """ Verify the endpoint only works with the service worker """
- data = {
- "username_mappings": [
- {"test_username_1": "test_new_username_1"},
- {"test_username_2": "test_new_username_2"}
- ]
- }
-
- # Test unauthenticated
- response = self.client.post(self.url, data)
- assert response.status_code == 403
-
- # Test non-service worker
- random_user = UserFactory()
- response = self.call_api(random_user, APIClient(), data)
- assert response.status_code == 403
-
- # Test service worker
- response = self.call_api(self.worker, self.worker_client, data)
- assert response.status_code == 200
-
- def test_basic(self):
- """ Check successful replacement """
- data = {
- "username_mappings": [
- {self.user.username: self.new_username},
- ]
- }
- expected_response = {
- 'failed_replacements': [],
- 'successful_replacements': data["username_mappings"]
- }
- self.register_get_username_replacement_response(self.user)
- response = self.call_api(self.worker, self.worker_client, data)
- assert response.status_code == 200
- assert response.data == expected_response
-
- def test_not_authenticated(self):
- """
- Override the parent implementation of this, we JWT auth for this API
- """
- pass # lint-amnesty, pylint: disable=unnecessary-pass
-
-
-@ddt.ddt
-@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
-class CourseTopicsViewTest(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase):
- """
- Tests for CourseTopicsView
- """
-
- def setUp(self):
- httpretty.reset()
- httpretty.enable()
- self.addCleanup(httpretty.reset)
- self.addCleanup(httpretty.disable)
- super().setUp()
- self.url = reverse("course_topics", kwargs={"course_id": str(self.course.id)})
- self.thread_counts_map = {
- "courseware-1": {"discussion": 2, "question": 3},
- "courseware-2": {"discussion": 4, "question": 5},
- "courseware-3": {"discussion": 7, "question": 2},
- }
- self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map)
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
-
- def create_course(self, blocks_count, module_store, topics):
- """
- Create a course in a specified module store with discussion xblocks and topics
- """
- course = CourseFactory.create(
- org="a",
- course="b",
- run="c",
- start=datetime.now(UTC),
- default_store=module_store,
- discussion_topics=topics
- )
- CourseEnrollmentFactory.create(user=self.user, course_id=course.id)
- course_url = reverse("course_topics", kwargs={"course_id": str(course.id)})
- # add some discussion xblocks
- for i in range(blocks_count):
- BlockFactory.create(
- parent_location=course.location,
- category='discussion',
- discussion_id=f'id_module_{i}',
- discussion_category=f'Category {i}',
- discussion_target=f'Discussion {i}',
- publish_item=False,
- )
- return course_url, course.id
-
- def make_discussion_xblock(self, topic_id, category, subcategory, **kwargs):
- """
- Build a discussion xblock in self.course
- """
- BlockFactory.create(
- parent_location=self.course.location,
- category="discussion",
- discussion_id=topic_id,
- discussion_category=category,
- discussion_target=subcategory,
- **kwargs
- )
-
- def test_404(self):
- response = self.client.get(
- reverse("course_topics", kwargs={"course_id": "non/existent/course"})
- )
- self.assert_response_correct(
- response,
- 404,
- {"developer_message": "Course not found."}
- )
-
- def test_basic(self):
- response = self.client.get(self.url)
- self.assert_response_correct(
- response,
- 200,
- {
- "courseware_topics": [],
- "non_courseware_topics": [{
- "id": "test_topic",
- "name": "Test Topic",
- "children": [],
- "thread_list_url": 'http://testserver/api/discussion/v1/threads/'
- '?course_id=course-v1%3Ax%2By%2Bz&topic_id=test_topic',
- "thread_counts": {"discussion": 0, "question": 0},
- }],
- }
- )
-
- @ddt.data(
- (2, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}),
- (2, ModuleStoreEnum.Type.split, 2,
- {"Test Topic 1": {"id": "test_topic_1"}, "Test Topic 2": {"id": "test_topic_2"}}),
- (10, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}),
- )
- @ddt.unpack
- def test_bulk_response(self, blocks_count, module_store, mongo_calls, topics):
- course_url, course_id = self.create_course(blocks_count, module_store, topics)
- self.register_get_course_commentable_counts_response(course_id, {})
- with check_mongo_calls(mongo_calls):
- with modulestore().default_store(module_store):
- self.client.get(course_url)
-
- def test_discussion_topic_404(self):
- """
- Tests discussion topic does not exist for the given topic id.
- """
- topic_id = "courseware-topic-id"
- self.make_discussion_xblock(topic_id, "test_category", "test_target")
- url = f"{self.url}?topic_id=invalid_topic_id"
- response = self.client.get(url)
- self.assert_response_correct(
- response,
- 404,
- {"developer_message": "Discussion not found for 'invalid_topic_id'."}
- )
-
- def test_topic_id(self):
- """
- Tests discussion topic details against a requested topic id
- """
- topic_id_1 = "topic_id_1"
- topic_id_2 = "topic_id_2"
- self.make_discussion_xblock(topic_id_1, "test_category_1", "test_target_1")
- self.make_discussion_xblock(topic_id_2, "test_category_2", "test_target_2")
- url = f"{self.url}?topic_id=topic_id_1,topic_id_2"
- response = self.client.get(url)
- self.assert_response_correct(
- response,
- 200,
- {
- "non_courseware_topics": [],
- "courseware_topics": [
- {
- "children": [{
- "children": [],
- "id": "topic_id_1",
- "thread_list_url": "http://testserver/api/discussion/v1/threads/?"
- "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1",
- "name": "test_target_1",
- "thread_counts": {"discussion": 0, "question": 0},
- }],
- "id": None,
- "thread_list_url": "http://testserver/api/discussion/v1/threads/?"
- "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1",
- "name": "test_category_1",
- "thread_counts": None,
- },
- {
- "children":
- [{
- "children": [],
- "id": "topic_id_2",
- "thread_list_url": "http://testserver/api/discussion/v1/threads/?"
- "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2",
- "name": "test_target_2",
- "thread_counts": {"discussion": 0, "question": 0},
- }],
- "id": None,
- "thread_list_url": "http://testserver/api/discussion/v1/threads/?"
- "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2",
- "name": "test_category_2",
- "thread_counts": None,
- }
- ]
- }
- )
-
- @override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True)
- def test_new_course_structure_response(self):
- """
- Tests whether the new structure is available on old topics API
- (For mobile compatibility)
- """
- chapter = BlockFactory.create(
- parent_location=self.course.location,
- category='chapter',
- display_name="Week 1",
- start=datetime(2015, 3, 1, tzinfo=UTC),
- )
- sequential = BlockFactory.create(
- parent_location=chapter.location,
- category='sequential',
- display_name="Lesson 1",
- start=datetime(2015, 3, 1, tzinfo=UTC),
- )
- BlockFactory.create(
- parent_location=sequential.location,
- category='vertical',
- display_name='vertical',
- start=datetime(2015, 4, 1, tzinfo=UTC),
- )
- DiscussionsConfiguration.objects.create(
- context_key=self.course.id,
- provider_type=Provider.OPEN_EDX
- )
- update_discussions_settings_from_course_task(str(self.course.id))
- response = json.loads(self.client.get(self.url).content.decode())
- keys = ['children', 'id', 'name', 'thread_counts', 'thread_list_url']
- assert list(response.keys()) == ['courseware_topics', 'non_courseware_topics']
- assert len(response['courseware_topics']) == 1
- courseware_keys = list(response['courseware_topics'][0].keys())
- courseware_keys.sort()
- assert courseware_keys == keys
- assert len(response['non_courseware_topics']) == 1
- non_courseware_keys = list(response['non_courseware_topics'][0].keys())
- non_courseware_keys.sort()
- assert non_courseware_keys == keys
-
-
-@ddt.ddt
-@mock.patch('lms.djangoapps.discussion.rest_api.api._get_course', mock.Mock())
-@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
-@override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True)
-class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase):
- """
- Tests for CourseTopicsViewV3
- """
-
- def setUp(self) -> None:
- super().setUp()
- self.password = self.TEST_PASSWORD
- self.user = UserFactory.create(password=self.password)
- self.client.login(username=self.user.username, password=self.password)
- self.staff = AdminFactory.create()
- self.course = CourseFactory.create(
- start=datetime(2020, 1, 1),
- end=datetime(2028, 1, 1),
- enrollment_start=datetime(2020, 1, 1),
- enrollment_end=datetime(2028, 1, 1),
- discussion_topics={"Course Wide Topic": {
- "id": 'course-wide-topic',
- "usage_key": None,
- }}
- )
- self.chapter = BlockFactory.create(
- parent_location=self.course.location,
- category='chapter',
- display_name="Week 1",
- start=datetime(2015, 3, 1, tzinfo=UTC),
- )
- self.sequential = BlockFactory.create(
- parent_location=self.chapter.location,
- category='sequential',
- display_name="Lesson 1",
- start=datetime(2015, 3, 1, tzinfo=UTC),
- )
- self.verticals = [
- BlockFactory.create(
- parent_location=self.sequential.location,
- category='vertical',
- display_name='vertical',
- start=datetime(2015, 4, 1, tzinfo=UTC),
- )
- ]
- course_key = self.course.id
- self.config = DiscussionsConfiguration.objects.create(context_key=course_key, provider_type=Provider.OPEN_EDX)
- topic_links = []
- update_discussions_settings_from_course_task(str(course_key))
- topic_id_query = DiscussionTopicLink.objects.filter(context_key=course_key).values_list(
- 'external_id', flat=True,
- )
- topic_ids = list(topic_id_query.order_by('ordering'))
- DiscussionTopicLink.objects.bulk_create(topic_links)
- self.topic_stats = {
- **{topic_id: dict(discussion=random.randint(0, 10), question=random.randint(0, 10))
- for topic_id in set(topic_ids)},
- topic_ids[0]: dict(discussion=0, question=0),
- }
- patcher = mock.patch(
- 'lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts',
- mock.Mock(return_value=self.topic_stats),
- )
- patcher.start()
- self.addCleanup(patcher.stop)
- self.url = reverse("course_topics_v3", kwargs={"course_id": str(self.course.id)})
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
-
- def test_basic(self):
- response = self.client.get(self.url)
- data = json.loads(response.content.decode())
- expected_non_courseware_keys = [
- 'id', 'usage_key', 'name', 'thread_counts', 'enabled_in_context',
- 'courseware'
- ]
- expected_courseware_keys = [
- 'id', 'block_id', 'lms_web_url', 'legacy_web_url', 'student_view_url',
- 'type', 'display_name', 'children', 'courseware'
- ]
- assert response.status_code == 200
- assert len(data) == 2
- non_courseware_topic_keys = list(data[0].keys())
- assert non_courseware_topic_keys == expected_non_courseware_keys
- courseware_topic_keys = list(data[1].keys())
- assert courseware_topic_keys == expected_courseware_keys
- expected_courseware_keys.remove('courseware')
- sequential_keys = list(data[1]['children'][0].keys())
- assert sequential_keys == (expected_courseware_keys + ['thread_counts'])
- expected_non_courseware_keys.remove('courseware')
- vertical_keys = list(data[1]['children'][0]['children'][0].keys())
- assert vertical_keys == expected_non_courseware_keys
-
-
-@ddt.ddt
-@httpretty.activate
-@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
-class LearnerThreadViewAPITest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
- """Tests for LearnerThreadView list"""
-
- def setUp(self):
- """
- Sets up the test case
- """
- super().setUp()
- self.author = self.user
- self.remove_keys = [
- "abuse_flaggers",
- "body",
- "children",
- "commentable_id",
- "endorsed",
- "last_activity_at",
- "resp_total",
- "thread_type",
- "user_id",
- "username",
- "votes",
- ]
- self.replace_keys = [
- {"from": "unread_comments_count", "to": "unread_comment_count"},
- {"from": "comments_count", "to": "comment_count"},
- ]
- self.add_keys = [
- {"key": "author", "value": self.author.username},
- {"key": "abuse_flagged", "value": False},
- {"key": "author_label", "value": None},
- {"key": "can_delete", "value": True},
- {"key": "close_reason", "value": None},
- {
- "key": "comment_list_url",
- "value": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread"
- },
- {
- "key": "editable_fields",
- "value": [
- 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body',
- 'read', 'title', 'topic_id', 'type'
- ]
- },
- {"key": "endorsed_comment_list_url", "value": None},
- {"key": "following", "value": False},
- {"key": "group_name", "value": None},
- {"key": "has_endorsed", "value": False},
- {"key": "last_edit", "value": None},
- {"key": "non_endorsed_comment_list_url", "value": None},
- {"key": "preview_body", "value": "Test body"},
- {"key": "raw_body", "value": "Test body"},
-
- {"key": "rendered_body", "value": "Test body
"},
- {"key": "response_count", "value": 0},
- {"key": "topic_id", "value": "test_topic"},
- {"key": "type", "value": "discussion"},
- {"key": "users", "value": {
- self.user.username: {
- "profile": {
- "image": {
- "has_image": False,
- "image_url_full": "http://testserver/static/default_500.png",
- "image_url_large": "http://testserver/static/default_120.png",
- "image_url_medium": "http://testserver/static/default_50.png",
- "image_url_small": "http://testserver/static/default_30.png",
- }
- }
- }
- }},
- {"key": "vote_count", "value": 4},
- {"key": "voted", "value": False},
-
- ]
- self.url = reverse("discussion_learner_threads", kwargs={'course_id': str(self.course.id)})
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
-
- def update_thread(self, thread):
- """
- This function updates the thread by adding and remove some keys.
- Value of these keys has been defined in setUp function
- """
- for element in self.add_keys:
- thread[element['key']] = element['value']
- for pair in self.replace_keys:
- thread[pair['to']] = thread.pop(pair['from'])
- for key in self.remove_keys:
- thread.pop(key)
- thread['comment_count'] += 1
- return thread
-
- def test_basic(self):
- """
- Tests the data is fetched correctly
-
- Note: test_basic is required as the name because DiscussionAPIViewTestMixin
- calls this test case automatically
- """
- self.register_get_user_response(self.user)
- expected_cs_comments_response = {
- "collection": [make_minimal_cs_thread({
- "id": "test_thread",
- "course_id": str(self.course.id),
- "commentable_id": "test_topic",
- "user_id": str(self.user.id),
- "username": self.user.username,
- "created_at": "2015-04-28T00:00:00Z",
- "updated_at": "2015-04-28T11:11:11Z",
- "title": "Test Title",
- "body": "Test body",
- "votes": {"up_count": 4},
- "comments_count": 5,
- "unread_comments_count": 3,
- "closed_by_label": None,
- "edit_by_label": None,
- })],
- "page": 1,
- "num_pages": 1,
- }
- self.register_user_active_threads(self.user.id, expected_cs_comments_response)
- self.url += f"?username={self.user.username}"
- response = self.client.get(self.url)
- assert response.status_code == 200
- response_data = json.loads(response.content.decode('utf-8'))
- expected_api_response = expected_cs_comments_response['collection']
-
- for thread in expected_api_response:
- self.update_thread(thread)
-
- assert response_data['results'] == expected_api_response
- assert response_data['pagination'] == {
- "next": None,
- "previous": None,
- "count": 1,
- "num_pages": 1,
- }
-
- def test_no_username_given(self):
- """
- Tests that 404 response is returned when no username is passed
- """
- response = self.client.get(self.url)
- assert response.status_code == 404
-
- def test_not_authenticated(self):
- """
- This test is called by DiscussionAPIViewTestMixin and is not required in
- our case
- """
- assert True
-
- @ddt.data("None", "discussion", "question")
- def test_thread_type_by(self, thread_type):
- """
- Tests the thread_type parameter
-
- Arguments:
- thread_type (str): Value of thread_type can be 'None',
- 'discussion' and 'question'
- """
- threads = [make_minimal_cs_thread({
- "id": "test_thread",
- "course_id": str(self.course.id),
- "commentable_id": "test_topic",
- "user_id": str(self.user.id),
- "username": self.user.username,
- "created_at": "2015-04-28T00:00:00Z",
- "updated_at": "2015-04-28T11:11:11Z",
- "title": "Test Title",
- "body": "Test body",
- "votes": {"up_count": 4},
- "comments_count": 5,
- "unread_comments_count": 3,
- })]
- expected_cs_comments_response = {
- "collection": threads,
- "page": 1,
- "num_pages": 1,
- }
- self.register_get_user_response(self.user)
- self.register_user_active_threads(self.user.id, expected_cs_comments_response)
- response = self.client.get(
- self.url,
- {
- "course_id": str(self.course.id),
- "username": self.user.username,
- "thread_type": thread_type,
- }
- )
- assert response.status_code == 200
- self.assert_last_query_params({
- "user_id": [str(self.user.id)],
- "course_id": [str(self.course.id)],
- "page": ["1"],
- "per_page": ["10"],
- "thread_type": [thread_type],
- "sort_key": ['activity'],
- "count_flagged": ["False"]
- })
-
- @ddt.data(
- ("last_activity_at", "activity"),
- ("comment_count", "comments"),
- ("vote_count", "votes")
- )
- @ddt.unpack
- def test_order_by(self, http_query, cc_query):
- """
- Tests the order_by parameter for active threads
-
- Arguments:
- http_query (str): Query string sent in the http request
- cc_query (str): Query string used for the comments client service
- """
- threads = [make_minimal_cs_thread({
- "id": "test_thread",
- "course_id": str(self.course.id),
- "commentable_id": "test_topic",
- "user_id": str(self.user.id),
- "username": self.user.username,
- "created_at": "2015-04-28T00:00:00Z",
- "updated_at": "2015-04-28T11:11:11Z",
- "title": "Test Title",
- "body": "Test body",
- "votes": {"up_count": 4},
- "comments_count": 5,
- "unread_comments_count": 3,
- })]
- expected_cs_comments_response = {
- "collection": threads,
- "page": 1,
- "num_pages": 1,
- }
- self.register_get_user_response(self.user)
- self.register_user_active_threads(self.user.id, expected_cs_comments_response)
- response = self.client.get(
- self.url,
- {
- "course_id": str(self.course.id),
- "username": self.user.username,
- "order_by": http_query,
- }
- )
- assert response.status_code == 200
- self.assert_last_query_params({
- "user_id": [str(self.user.id)],
- "course_id": [str(self.course.id)],
- "page": ["1"],
- "per_page": ["10"],
- "sort_key": [cc_query],
- "count_flagged": ["False"]
- })
-
- @ddt.data("flagged", "unanswered", "unread", "unresponded")
- def test_status_by(self, post_status):
- """
- Tests the post_status parameter
-
- Arguments:
- post_status (str): Value of post_status can be 'flagged',
- 'unanswered' and 'unread'
- """
- threads = [make_minimal_cs_thread({
- "id": "test_thread",
- "course_id": str(self.course.id),
- "commentable_id": "test_topic",
- "user_id": str(self.user.id),
- "username": self.user.username,
- "created_at": "2015-04-28T00:00:00Z",
- "updated_at": "2015-04-28T11:11:11Z",
- "title": "Test Title",
- "body": "Test body",
- "votes": {"up_count": 4},
- "comments_count": 5,
- "unread_comments_count": 3,
- })]
- expected_cs_comments_response = {
- "collection": threads,
- "page": 1,
- "num_pages": 1,
- }
- self.register_get_user_response(self.user)
- self.register_user_active_threads(self.user.id, expected_cs_comments_response)
- response = self.client.get(
- self.url,
- {
- "course_id": str(self.course.id),
- "username": self.user.username,
- "status": post_status,
- }
- )
- if post_status == "flagged":
- assert response.status_code == 403
- else:
- assert response.status_code == 200
- self.assert_last_query_params({
- "user_id": [str(self.user.id)],
- "course_id": [str(self.course.id)],
- "page": ["1"],
- "per_page": ["10"],
- post_status: ['True'],
- "sort_key": ['activity'],
- "count_flagged": ["False"]
- })
-
-
-@ddt.ddt
-class CourseDiscussionSettingsAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTestCase):
- """
- Test the course discussion settings handler API endpoint.
- """
- @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
- def setUp(self):
- super().setUp()
- self.course = CourseFactory.create(
- org="x",
- course="y",
- run="z",
- start=datetime.now(UTC),
- discussion_topics={"Test Topic": {"id": "test_topic"}}
- )
- self.path = reverse('discussion_course_settings', kwargs={'course_id': str(self.course.id)})
- self.password = self.TEST_PASSWORD
- self.user = UserFactory(username='staff', password=self.password, is_staff=True)
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
-
- def _get_oauth_headers(self, user):
- """Return the OAuth headers for testing OAuth authentication"""
- access_token = AccessTokenFactory.create(user=user, application=ApplicationFactory()).token
- headers = {
- 'HTTP_AUTHORIZATION': 'Bearer ' + access_token
- }
- return headers
-
- def _login_as_staff(self):
- """Log the client in as the staff."""
- self.client.login(username=self.user.username, password=self.password)
-
- def _login_as_discussion_staff(self):
- user = UserFactory(username='abc', password='abc')
- role = Role.objects.create(name='Administrator', course_id=self.course.id)
- role.users.set([user])
- self.client.login(username=user.username, password='abc')
-
- def _create_divided_discussions(self):
- """Create some divided discussions for testing."""
- divided_inline_discussions = ['Topic A', ]
- divided_course_wide_discussions = ['Topic B', ]
- divided_discussions = divided_inline_discussions + divided_course_wide_discussions
-
- BlockFactory.create(
- parent=self.course,
- category='discussion',
- discussion_id=topic_name_to_id(self.course, 'Topic A'),
- discussion_category='Chapter',
- discussion_target='Discussion',
- start=datetime.now()
- )
- discussion_topics = {
- "Topic B": {"id": "Topic B"},
- }
- config_course_cohorts(self.course, is_cohorted=True)
- config_course_discussions(
- self.course,
- discussion_topics=discussion_topics,
- divided_discussions=divided_discussions
- )
- return divided_inline_discussions, divided_course_wide_discussions
-
- def _get_expected_response(self):
- """Return the default expected response before any changes to the discussion settings."""
- return {
- 'always_divide_inline_discussions': False,
- 'divided_inline_discussions': [],
- 'divided_course_wide_discussions': [],
- 'id': 1,
- 'division_scheme': 'cohort',
- 'available_division_schemes': ['cohort'],
- 'reported_content_email_notifications': False,
- }
-
- def patch_request(self, data, headers=None):
- headers = headers if headers else {}
- return self.client.patch(self.path, json.dumps(data), content_type='application/merge-patch+json', **headers)
-
- def _assert_current_settings(self, expected_response):
- """Validate the current discussion settings against the expected response."""
- response = self.client.get(self.path)
- assert response.status_code == 200
- content = json.loads(response.content.decode('utf-8'))
- assert content == expected_response
-
- def _assert_patched_settings(self, data, expected_response):
- """Validate the patched settings against the expected response."""
- response = self.patch_request(data)
- assert response.status_code == 204
- self._assert_current_settings(expected_response)
-
- @ddt.data('get', 'patch')
- def test_authentication_required(self, method):
- """Test and verify that authentication is required for this endpoint."""
- self.client.logout()
- response = getattr(self.client, method)(self.path)
- assert response.status_code == 401
-
- @ddt.data(
- {'is_staff': False, 'get_status': 403, 'put_status': 403},
- {'is_staff': True, 'get_status': 200, 'put_status': 204},
- )
- @ddt.unpack
- def test_oauth(self, is_staff, get_status, put_status):
- """Test that OAuth authentication works for this endpoint."""
- user = UserFactory(is_staff=is_staff)
- headers = self._get_oauth_headers(user)
- self.client.logout()
-
- response = self.client.get(self.path, **headers)
- assert response.status_code == get_status
-
- response = self.patch_request(
- {'always_divide_inline_discussions': True}, headers
- )
- assert response.status_code == put_status
-
- def test_non_existent_course_id(self):
- """Test the response when this endpoint is passed a non-existent course id."""
- self._login_as_staff()
- response = self.client.get(
- reverse('discussion_course_settings', kwargs={
- 'course_id': 'course-v1:a+b+c'
- })
- )
- assert response.status_code == 404
-
- def test_patch_request_by_discussion_staff(self):
- """Test the response when patch request is sent by a user with discussions staff role."""
- self._login_as_discussion_staff()
- response = self.patch_request(
- {'always_divide_inline_discussions': True}
- )
- assert response.status_code == 403
-
- def test_get_request_by_discussion_staff(self):
- """Test the response when get request is sent by a user with discussions staff role."""
- self._login_as_discussion_staff()
- divided_inline_discussions, divided_course_wide_discussions = self._create_divided_discussions()
- response = self.client.get(self.path)
- assert response.status_code == 200
- expected_response = self._get_expected_response()
- expected_response['divided_course_wide_discussions'] = [
- topic_name_to_id(self.course, name) for name in divided_course_wide_discussions
- ]
- expected_response['divided_inline_discussions'] = [
- topic_name_to_id(self.course, name) for name in divided_inline_discussions
- ]
- content = json.loads(response.content.decode('utf-8'))
- assert content == expected_response
-
- def test_get_request_by_non_staff_user(self):
- """Test the response when get request is sent by a regular user with no staff role."""
- user = UserFactory(username='abc', password='abc')
- self.client.login(username=user.username, password='abc')
- response = self.client.get(self.path)
- assert response.status_code == 403
-
- def test_patch_request_by_non_staff_user(self):
- """Test the response when patch request is sent by a regular user with no staff role."""
- user = UserFactory(username='abc', password='abc')
- self.client.login(username=user.username, password='abc')
- response = self.patch_request(
- {'always_divide_inline_discussions': True}
- )
- assert response.status_code == 403
-
- def test_get_settings(self):
- """Test the current discussion settings against the expected response."""
- divided_inline_discussions, divided_course_wide_discussions = self._create_divided_discussions()
- self._login_as_staff()
- response = self.client.get(self.path)
- assert response.status_code == 200
- expected_response = self._get_expected_response()
- expected_response['divided_course_wide_discussions'] = [
- topic_name_to_id(self.course, name) for name in divided_course_wide_discussions
- ]
- expected_response['divided_inline_discussions'] = [
- topic_name_to_id(self.course, name) for name in divided_inline_discussions
- ]
- content = json.loads(response.content.decode('utf-8'))
- assert content == expected_response
-
- def test_available_schemes(self):
- """Test the available division schemes against the expected response."""
- config_course_cohorts(self.course, is_cohorted=False)
- self._login_as_staff()
- expected_response = self._get_expected_response()
- expected_response['available_division_schemes'] = []
- self._assert_current_settings(expected_response)
-
- CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT)
- CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED)
-
- expected_response['available_division_schemes'] = [CourseDiscussionSettings.ENROLLMENT_TRACK]
- self._assert_current_settings(expected_response)
-
- config_course_cohorts(self.course, is_cohorted=True)
- expected_response['available_division_schemes'] = [
- CourseDiscussionSettings.COHORT, CourseDiscussionSettings.ENROLLMENT_TRACK
- ]
- self._assert_current_settings(expected_response)
-
- def test_empty_body_patch_request(self):
- """Test the response status code on sending a PATCH request with an empty body or missing fields."""
- self._login_as_staff()
- response = self.patch_request("")
- assert response.status_code == 400
-
- response = self.patch_request({})
- assert response.status_code == 400
-
- @ddt.data(
- {'abc': 123},
- {'divided_course_wide_discussions': 3},
- {'divided_inline_discussions': 'a'},
- {'always_divide_inline_discussions': ['a']},
- {'division_scheme': True}
- )
- def test_invalid_body_parameters(self, body):
- """Test the response status code on sending a PATCH request with parameters having incorrect types."""
- self._login_as_staff()
- response = self.patch_request(body)
- assert response.status_code == 400
-
- def test_update_always_divide_inline_discussion_settings(self):
- """Test whether the 'always_divide_inline_discussions' setting is updated."""
- config_course_cohorts(self.course, is_cohorted=True)
- self._login_as_staff()
- expected_response = self._get_expected_response()
- self._assert_current_settings(expected_response)
- expected_response['always_divide_inline_discussions'] = True
-
- self._assert_patched_settings({'always_divide_inline_discussions': True}, expected_response)
-
- def test_update_course_wide_discussion_settings(self):
- """Test whether the 'divided_course_wide_discussions' setting is updated."""
- discussion_topics = {
- 'Topic B': {'id': 'Topic B'}
- }
- config_course_cohorts(self.course, is_cohorted=True)
- config_course_discussions(self.course, discussion_topics=discussion_topics)
- expected_response = self._get_expected_response()
- self._login_as_staff()
- self._assert_current_settings(expected_response)
- expected_response['divided_course_wide_discussions'] = [
- topic_name_to_id(self.course, "Topic B")
- ]
- self._assert_patched_settings(
- {'divided_course_wide_discussions': [topic_name_to_id(self.course, "Topic B")]},
- expected_response
- )
- expected_response['divided_course_wide_discussions'] = []
- self._assert_patched_settings(
- {'divided_course_wide_discussions': []},
- expected_response
- )
-
- def test_update_inline_discussion_settings(self):
- """Test whether the 'divided_inline_discussions' setting is updated."""
- config_course_cohorts(self.course, is_cohorted=True)
- self._login_as_staff()
- expected_response = self._get_expected_response()
- self._assert_current_settings(expected_response)
-
- now = datetime.now()
- BlockFactory.create(
- parent_location=self.course.location,
- category='discussion',
- discussion_id='Topic_A',
- discussion_category='Chapter',
- discussion_target='Discussion',
- start=now
- )
- expected_response['divided_inline_discussions'] = ['Topic_A', ]
- self._assert_patched_settings({'divided_inline_discussions': ['Topic_A']}, expected_response)
-
- expected_response['divided_inline_discussions'] = []
- self._assert_patched_settings({'divided_inline_discussions': []}, expected_response)
-
- def test_update_division_scheme(self):
- """Test whether the 'division_scheme' setting is updated."""
- config_course_cohorts(self.course, is_cohorted=True)
- self._login_as_staff()
- expected_response = self._get_expected_response()
- self._assert_current_settings(expected_response)
- expected_response['division_scheme'] = 'none'
- self._assert_patched_settings({'division_scheme': 'none'}, expected_response)
-
- def test_update_reported_content_email_notifications(self):
- """Test whether the 'reported_content_email_notifications' setting is updated."""
- config_course_cohorts(self.course, is_cohorted=True)
- config_course_discussions(self.course, reported_content_email_notifications=True)
- expected_response = self._get_expected_response()
- expected_response['reported_content_email_notifications'] = True
- self._login_as_staff()
- self._assert_current_settings(expected_response)
-
-
-@ddt.ddt
-class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTestCase):
- """
- Test the course discussion roles management endpoint.
- """
- @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
- def setUp(self):
- super().setUp()
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
- self.course = CourseFactory.create(
- org="x",
- course="y",
- run="z",
- start=datetime.now(UTC),
- )
- self.password = self.TEST_PASSWORD
- self.user = UserFactory(username='staff', password=self.password, is_staff=True)
- course_key = CourseKey.from_string('course-v1:x+y+z')
- seed_permissions_roles(course_key)
-
- @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
- def path(self, course_id=None, role=None):
- """Return the URL path to the endpoint based on the provided arguments."""
- course_id = str(self.course.id) if course_id is None else course_id
- role = 'Moderator' if role is None else role
- return reverse(
- 'discussion_course_roles',
- kwargs={'course_id': course_id, 'rolename': role}
- )
-
- def _get_oauth_headers(self, user):
- """Return the OAuth headers for testing OAuth authentication."""
- access_token = AccessTokenFactory.create(user=user, application=ApplicationFactory()).token
- headers = {
- 'HTTP_AUTHORIZATION': 'Bearer ' + access_token
- }
- return headers
-
- def _login_as_staff(self):
- """Log the client is as the staff user."""
- self.client.login(username=self.user.username, password=self.password)
-
- def _create_and_enroll_users(self, count):
- """Create 'count' number of users and enroll them in self.course."""
- users = []
- for _ in range(count):
- user = UserFactory()
- CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
- users.append(user)
- return users
-
- def _add_users_to_role(self, users, rolename):
- """Add the given users to the given role."""
- role = Role.objects.get(name=rolename, course_id=self.course.id)
- for user in users:
- role.users.add(user)
-
- def post(self, role, user_id, action):
- """Make a POST request to the endpoint using the provided parameters."""
- self._login_as_staff()
- return self.client.post(self.path(role=role), {'user_id': user_id, 'action': action})
-
- @ddt.data('get', 'post')
- def test_authentication_required(self, method):
- """Test and verify that authentication is required for this endpoint."""
- self.client.logout()
- response = getattr(self.client, method)(self.path())
- assert response.status_code == 401
-
- def test_oauth(self):
- """Test that OAuth authentication works for this endpoint."""
- oauth_headers = self._get_oauth_headers(self.user)
- self.client.logout()
- response = self.client.get(self.path(), **oauth_headers)
- assert response.status_code == 200
- body = {'user_id': 'staff', 'action': 'allow'}
- response = self.client.post(self.path(), body, format='json', **oauth_headers)
- assert response.status_code == 200
-
- @ddt.data(
- {'username': 'u1', 'is_staff': False, 'expected_status': 403},
- {'username': 'u2', 'is_staff': True, 'expected_status': 200},
- )
- @ddt.unpack
- def test_staff_permission_required(self, username, is_staff, expected_status):
- """Test and verify that only users with staff permission can access this endpoint."""
- UserFactory(username=username, password='edx', is_staff=is_staff)
- self.client.login(username=username, password='edx')
- response = self.client.get(self.path())
- assert response.status_code == expected_status
-
- response = self.client.post(self.path(), {'user_id': username, 'action': 'allow'}, format='json')
- assert response.status_code == expected_status
-
- def test_non_existent_course_id(self):
- """Test the response when the endpoint URL contains a non-existent course id."""
- self._login_as_staff()
- path = self.path(course_id='course-v1:a+b+c')
- response = self.client.get(path)
-
- assert response.status_code == 404
-
- response = self.client.post(path)
- assert response.status_code == 404
-
- def test_non_existent_course_role(self):
- """Test the response when the endpoint URL contains a non-existent role."""
- self._login_as_staff()
- path = self.path(role='A')
- response = self.client.get(path)
-
- assert response.status_code == 400
-
- response = self.client.post(path)
- assert response.status_code == 400
-
- @ddt.data(
- {'role': 'Moderator', 'count': 0},
- {'role': 'Moderator', 'count': 1},
- {'role': 'Group Moderator', 'count': 2},
- {'role': 'Community TA', 'count': 3},
- )
- @ddt.unpack
- def test_get_role_members(self, role, count):
- """Test the get role members endpoint response."""
- config_course_cohorts(self.course, is_cohorted=True)
- users = self._create_and_enroll_users(count=count)
-
- self._add_users_to_role(users, role)
- self._login_as_staff()
- response = self.client.get(self.path(role=role))
-
- assert response.status_code == 200
-
- content = json.loads(response.content.decode('utf-8'))
- assert content['course_id'] == 'course-v1:x+y+z'
- assert len(content['results']) == count
- expected_fields = ('username', 'email', 'first_name', 'last_name', 'group_name')
- for item in content['results']:
- for expected_field in expected_fields:
- assert expected_field in item
- assert content['division_scheme'] == 'cohort'
-
- def test_post_missing_body(self):
- """Test the response with a POST request without a body."""
- self._login_as_staff()
- response = self.client.post(self.path())
- assert response.status_code == 400
-
- @ddt.data(
- {'a': 1},
- {'user_id': 'xyz', 'action': 'allow'},
- {'user_id': 'staff', 'action': 123},
- )
- def test_missing_or_invalid_parameters(self, body):
- """
- Test the response when the POST request has missing required parameters or
- invalid values for the required parameters.
- """
- self._login_as_staff()
- response = self.client.post(self.path(), body)
- assert response.status_code == 400
-
- response = self.client.post(self.path(), body, format='json')
- assert response.status_code == 400
-
- @ddt.data(
- {'action': 'allow', 'user_in_role': False},
- {'action': 'allow', 'user_in_role': True},
- {'action': 'revoke', 'user_in_role': False},
- {'action': 'revoke', 'user_in_role': True}
- )
- @ddt.unpack
- def test_post_update_user_role(self, action, user_in_role):
- """Test the response when updating the user's role"""
- users = self._create_and_enroll_users(count=1)
- user = users[0]
- role = 'Moderator'
- if user_in_role:
- self._add_users_to_role(users, role)
-
- response = self.post(role, user.username, action)
- assert response.status_code == 200
- content = json.loads(response.content.decode('utf-8'))
- assertion = self.assertTrue if action == 'allow' else self.assertFalse
- assertion(any(user.username in x['username'] for x in content['results']))
-
-
-@ddt.ddt
-@httpretty.activate
-@override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True)
-class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceMockMixin, APITestCase,
- SharedModuleStoreTestCase):
- """
- Tests for the course stats endpoint
- """
-
- @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
- def setUp(self) -> None:
- super().setUp()
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
- self.course = CourseFactory.create()
- self.course_key = str(self.course.id)
- seed_permissions_roles(self.course.id)
- self.user = UserFactory(username='user')
- self.moderator = UserFactory(username='moderator')
- moderator_role = Role.objects.get(name="Moderator", course_id=self.course.id)
- moderator_role.users.add(self.moderator)
- self.stats = [
- {
- "active_flags": random.randint(0, 3),
- "inactive_flags": random.randint(0, 2),
- "replies": random.randint(0, 30),
- "responses": random.randint(0, 100),
- "threads": random.randint(0, 10),
- "username": f"user-{idx}"
- }
- for idx in range(10)
- ]
-
- for stat in self.stats:
- user = UserFactory.create(
- username=stat['username'],
- email=f"{stat['username']}@example.com",
- password=self.TEST_PASSWORD
- )
- CourseEnrollment.enroll(user, self.course.id, mode='audit')
-
- CourseEnrollment.enroll(self.moderator, self.course.id, mode='audit')
- self.stats_without_flags = [{**stat, "active_flags": None, "inactive_flags": None} for stat in self.stats]
- self.register_course_stats_response(self.course_key, self.stats, 1, 3)
- self.url = reverse("discussion_course_activity_stats", kwargs={"course_key_string": self.course_key})
-
- @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
- def test_regular_user(self):
- """
- Tests that for a regular user stats are returned without flag counts
- """
- self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
- response = self.client.get(self.url)
- data = response.json()
- assert data["results"] == self.stats_without_flags
-
- @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
- def test_moderator_user(self):
- """
- Tests that for a moderator user stats are returned with flag counts
- """
- self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
- response = self.client.get(self.url)
- data = response.json()
- assert data["results"] == self.stats
-
- @ddt.data(
- ("moderator", "flagged", "flagged"),
- ("moderator", "activity", "activity"),
- ("moderator", "recency", "recency"),
- ("moderator", None, "flagged"),
- ("user", None, "activity"),
- ("user", "activity", "activity"),
- ("user", "recency", "recency"),
- )
- @ddt.unpack
- @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
- def test_sorting(self, username, ordering_requested, ordering_performed):
- """
- Test valid sorting options and defaults
- """
- self.client.login(username=username, password=self.TEST_PASSWORD)
- params = {}
- if ordering_requested:
- params = {"order_by": ordering_requested}
- self.client.get(self.url, params)
- assert urlparse(
- httpretty.last_request().path # lint-amnesty, pylint: disable=no-member
- ).path == f"/api/v1/users/{self.course_key}/stats"
- assert parse_qs(
- urlparse(httpretty.last_request().path).query # lint-amnesty, pylint: disable=no-member
- ).get("sort_key", None) == [ordering_performed]
-
- @ddt.data("flagged", "xyz")
- @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
- def test_sorting_error_regular_user(self, order_by):
- """
- Test for invalid sorting options for regular users.
- """
- self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
- response = self.client.get(self.url, {"order_by": order_by})
- assert "order_by" in response.json()["field_errors"]
-
- @ddt.data(
- ('user', 'user-0,user-1,user-2,user-3,user-4,user-5,user-6,user-7,user-8,user-9'),
- ('moderator', 'moderator'),
- )
- @ddt.unpack
- @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
- def test_with_username_param(self, username_search_string, comma_separated_usernames):
- """
- Test for endpoint with username param.
- """
- params = {'username': username_search_string}
- self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
- self.client.get(self.url, params)
- assert urlparse(
- httpretty.last_request().path # lint-amnesty, pylint: disable=no-member
- ).path == f'/api/v1/users/{self.course_key}/stats'
- assert parse_qs(
- urlparse(httpretty.last_request().path).query # lint-amnesty, pylint: disable=no-member
- ).get('usernames', [None]) == [comma_separated_usernames]
-
- @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
- def test_with_username_param_with_no_matches(self):
- """
- Test for endpoint with username param with no matches.
- """
- params = {'username': 'unknown'}
- self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
- response = self.client.get(self.url, params)
- data = response.json()
- self.assertFalse(data['results'])
- assert data['pagination']['count'] == 0
-
- @ddt.data(
- 'user-0',
- 'USER-1',
- 'User-2',
- 'UsEr-3'
- )
- @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
- def test_with_username_param_case(self, username_search_string):
- """
- Test user search function is case-insensitive.
- """
- response = get_usernames_from_search_string(self.course_key, username_search_string, 1, 1)
- assert response == (username_search_string.lower(), 1, 1)
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py
index 4247cbcab0..40b2ca9154 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py
@@ -1,4 +1,3 @@
-# pylint: skip-file
"""
Tests for the external REST API endpoints of the Discussion API (views_v2.py).
@@ -8,44 +7,68 @@ and integration with the underlying discussion service. These tests ensure that
various user roles, input data, and edge cases, and that they return appropriate HTTP status codes and response bodies.
"""
-
import json
+import random
from datetime import datetime
from unittest import mock
import ddt
-from forum.backends.mongodb.comments import Comment
-from forum.backends.mongodb.threads import CommentThread
import httpretty
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.test import override_settings
from django.urls import reverse
+from edx_toggles.toggles.testutils import override_waffle_flag
+from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from rest_framework import status
from rest_framework.parsers import JSONParser
-from rest_framework.test import APIClient
+from rest_framework.test import APIClient, APITestCase
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
-from xmodule.modulestore.tests.factories import CourseFactory
-from common.djangoapps.student.tests.factories import (
- CourseEnrollmentFactory,
- UserFactory,
+from lms.djangoapps.discussion.django_comment_client.tests.utils import (
+ ForumsEnableMixin,
+ config_course_discussions,
+ topic_name_to_id,
)
-from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin
-from common.test.utils import disable_signal
-from lms.djangoapps.discussion.tests.utils import (
- make_minimal_cs_comment,
- make_minimal_cs_thread,
-)
-from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin
from lms.djangoapps.discussion.rest_api import api
from lms.djangoapps.discussion.rest_api.tests.utils import (
+ CommentsServiceMockMixin,
ForumMockUtilsMixin,
ProfileImageTestMixin,
make_paginated_api_response,
+ make_minimal_cs_comment,
+ make_minimal_cs_thread,
)
+from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string
+from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
+from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls
+
+from common.djangoapps.course_modes.models import CourseMode
+from common.djangoapps.course_modes.tests.factories import CourseModeFactory
+from common.djangoapps.student.tests.factories import (
+ AdminFactory,
+ CourseEnrollmentFactory,
+ SuperuserFactory,
+ UserFactory
+)
+from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment
+from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff
+from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin
+from common.test.utils import disable_signal
+
+from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
+from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider
+from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task
+from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
+from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory
+from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus
from openedx.core.djangoapps.django_comment_common.models import (
- FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT,
- assign_role
+ CourseDiscussionSettings, Role
)
+from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles
+from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage
@@ -657,7 +680,7 @@ class ThreadViewSetListTest(
@ddt.data(True, "true", "1")
def test_following_true(self, following):
self.register_get_user_response(self.user)
- self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0)
+ self.register_subscribed_threads_response(self.user, [], page=1, num_pages=1)
response = self.client.get(
self.url,
{
@@ -667,7 +690,7 @@ class ThreadViewSetListTest(
)
expected_response = make_paginated_api_response(
- results=[], count=0, num_pages=0, next_link=None, previous_link=None
+ results=[], count=0, num_pages=1, next_link=None, previous_link=None
)
expected_response.update({"text_search_rewrite": None})
self.assert_response_correct(response, 200, expected_response)
@@ -868,81 +891,1780 @@ class BulkDeleteUserPostsTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
Tests for the BulkDeleteUserPostsViewSet
"""
- def setUp(self):
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def setUp(self) -> None:
super().setUp()
- self.url = reverse("bulk_delete_user_posts", kwargs={"course_id": str(self.course.id)})
- self.user2 = UserFactory.create(password=self.password)
- CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id)
+ self.course = CourseFactory.create()
+ self.course_key = str(self.course.id)
+ seed_permissions_roles(self.course.id)
+ self.user = UserFactory(username='user')
+ self.moderator = UserFactory(username='moderator')
+ moderator_role = Role.objects.get(name="Moderator", course_id=self.course.id)
+ moderator_role.users.add(self.moderator)
+ self.stats = [
+ {
+ "active_flags": random.randint(0, 3),
+ "inactive_flags": random.randint(0, 2),
+ "replies": random.randint(0, 30),
+ "responses": random.randint(0, 100),
+ "threads": random.randint(0, 10),
+ "username": f"user-{idx}"
+ }
+ for idx in range(10)
+ ]
+
+ for stat in self.stats:
+ user = UserFactory.create(
+ username=stat['username'],
+ email=f"{stat['username']}@example.com",
+ password=self.TEST_PASSWORD
+ )
+ CourseEnrollment.enroll(user, self.course.id, mode='audit')
+
+ CourseEnrollment.enroll(self.moderator, self.course.id, mode='audit')
+ self.stats_without_flags = [{**stat, "active_flags": None, "inactive_flags": None} for stat in self.stats]
+ self.register_course_stats_response(self.course_key, self.stats, 1, 3)
+ self.url = reverse("discussion_course_activity_stats", kwargs={"course_key_string": self.course_key})
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ super().setUpClassAndForumMock()
+
+ @classmethod
+ def tearDownClass(cls):
+ super().tearDownClass()
+ super().disposeForumMocks()
+
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def test_regular_user(self):
+ """
+ Tests that for a regular user stats are returned without flag counts
+ """
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
+ response = self.client.get(self.url)
+ data = response.json()
+ assert data["results"] == self.stats_without_flags
+
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def test_moderator_user(self):
+ """
+ Tests that for a moderator user stats are returned with flag counts
+ """
+ self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
+ response = self.client.get(self.url)
+ data = response.json()
+ assert data["results"] == self.stats
+
+ @ddt.data(
+ ("moderator", "flagged", "flagged"),
+ ("moderator", "activity", "activity"),
+ ("moderator", "recency", "recency"),
+ ("moderator", None, "flagged"),
+ ("user", None, "activity"),
+ ("user", "activity", "activity"),
+ ("user", "recency", "recency"),
+ )
+ @ddt.unpack
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def test_sorting(self, username, ordering_requested, ordering_performed):
+ """
+ Test valid sorting options and defaults
+ """
+ self.client.login(username=username, password=self.TEST_PASSWORD)
+ params = {}
+ if ordering_requested:
+ params = {"order_by": ordering_requested}
+ self.client.get(self.url, params)
+ self.check_mock_called("get_user_course_stats")
+ params = self.get_mock_func_calls("get_user_course_stats")[-1][1]
+ assert params["sort_key"] == ordering_performed
+
+ @ddt.data("flagged", "xyz")
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def test_sorting_error_regular_user(self, order_by):
+ """
+ Test for invalid sorting options for regular users.
+ """
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
+ response = self.client.get(self.url, {"order_by": order_by})
+ assert "order_by" in response.json()["field_errors"]
+
+ @ddt.data(
+ ('user', 'user-0,user-1,user-2,user-3,user-4,user-5,user-6,user-7,user-8,user-9'),
+ ('moderator', 'moderator'),
+ )
+ @ddt.unpack
+ @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
+ def test_with_username_param(self, username_search_string, comma_separated_usernames):
+ """
+ Test for endpoint with username param.
+ """
+ params = {'username': username_search_string}
+ self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
+ self.client.get(self.url, params)
+ self.check_mock_called("get_user_course_stats")
+ params = self.get_mock_func_calls("get_user_course_stats")[-1][1]
+ assert params["usernames"] == comma_separated_usernames
+
+ @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
+ def test_with_username_param_with_no_matches(self):
+ """
+ Test for endpoint with username param with no matches.
+ """
+ params = {'username': 'unknown'}
+ self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
+ response = self.client.get(self.url, params)
+ data = response.json()
+ self.assertFalse(data['results'])
+ assert data['pagination']['count'] == 0
+
+ @ddt.data(
+ 'user-0',
+ 'USER-1',
+ 'User-2',
+ 'UsEr-3'
+ )
+ @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
+ def test_with_username_param_case(self, username_search_string):
+ """
+ Test user search function is case-insensitive.
+ """
+ response = get_usernames_from_search_string(self.course_key, username_search_string, 1, 1)
+ assert response == (username_search_string.lower(), 1, 1)
def test_basic(self):
"""
- Intentionally left empty because this test case is inherited from parent
+ Basic test method required by DiscussionAPIViewTestMixin
"""
- def mock_comment_and_thread_count(self, comment_count=1, thread_count=1):
+ def user_login(self):
"""
- Patches count_documents() for Comment and CommentThread._collection.
+ Authenticates the test client with the example user.
"""
- thread_collection = mock.MagicMock()
- thread_collection.count_documents.return_value = thread_count
- patch_thread = mock.patch.object(
- CommentThread, "_collection", new_callable=mock.PropertyMock, return_value=thread_collection
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
+
+
+@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+@override_settings(DISCUSSION_MODERATION_EDIT_REASON_CODES={"test-edit-reason": "Test Edit Reason"})
+@override_settings(DISCUSSION_MODERATION_CLOSE_REASON_CODES={"test-close-reason": "Test Close Reason"})
+class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
+ """Tests for CourseView"""
+
+ def setUp(self):
+ super().setUp()
+ self.url = reverse("discussion_course", kwargs={"course_id": str(self.course.id)})
+
+ def test_404(self):
+ response = self.client.get(
+ reverse("course_topics", kwargs={"course_id": "non/existent/course"})
+ )
+ self.assert_response_correct(
+ response,
+ 404,
+ {"developer_message": "Course not found."}
)
- comment_collection = mock.MagicMock()
- comment_collection.count_documents.return_value = comment_count
- patch_comment = mock.patch.object(
- Comment, "_collection", new_callable=mock.PropertyMock, return_value=comment_collection
+ def test_basic(self):
+ response = self.client.get(self.url)
+ self.assert_response_correct(
+ response,
+ 200,
+ {
+ "id": str(self.course.id),
+ "is_posting_enabled": True,
+ "blackouts": [],
+ "thread_list_url": "http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz",
+ "following_thread_list_url": (
+ "http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=True"
+ ),
+ "topics_url": "http://testserver/api/discussion/v1/course_topics/course-v1:x+y+z",
+ "enable_in_context": True,
+ "group_at_subsection": False,
+ "provider": "legacy",
+ "allow_anonymous": True,
+ "allow_anonymous_to_peers": False,
+ "has_moderation_privileges": False,
+ 'is_course_admin': False,
+ 'is_course_staff': False,
+ "is_group_ta": False,
+ 'is_user_admin': False,
+ "user_roles": ["Student"],
+ "edit_reasons": [{"code": "test-edit-reason", "label": "Test Edit Reason"}],
+ "post_close_reasons": [{"code": "test-close-reason", "label": "Test Close Reason"}],
+ 'show_discussions': True,
+ 'has_bulk_delete_privileges': False,
+ 'is_notify_all_learners_enabled': False,
+ 'captcha_settings': {'enabled': False, 'site_key': None},
+ 'is_email_verified': True,
+ 'only_verified_users_can_post': False,
+ 'content_creation_rate_limited': False,
+ }
)
- thread_mock = patch_thread.start()
- comment_mock = patch_comment.start()
- self.addCleanup(patch_comment.stop)
- self.addCleanup(patch_thread.stop)
- return thread_mock, comment_mock
+@ddt.ddt
+@httpretty.activate
+@mock.patch('django.conf.settings.USERNAME_REPLACEMENT_WORKER', 'test_replace_username_service_worker')
+@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+class ReplaceUsernamesViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
+ """Tests for ReplaceUsernamesView"""
- @ddt.data(FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT)
- def test_bulk_delete_denied_for_discussion_roles(self, role):
+ def setUp(self):
+ super().setUp()
+ self.worker = UserFactory()
+ self.worker.username = "test_replace_username_service_worker"
+ self.worker_client = APIClient()
+ self.new_username = "test_username_replacement"
+ self.url = reverse("replace_discussion_username")
+
+ def assert_response_correct(self, response, expected_status, expected_content):
"""
- Test bulk delete user posts denied with discussion roles.
+ Assert that the response has the given status code and content
"""
- thread_mock, comment_mock = self.mock_comment_and_thread_count(comment_count=1, thread_count=1)
- assign_role(self.course.id, self.user, role)
- response = self.client.post(
- f"{self.url}?username={self.user2.username}",
- format="json",
+ assert response.status_code == expected_status
+
+ if expected_content:
+ assert str(response.content) == expected_content
+
+ def build_jwt_headers(self, user):
+ """
+ Helper function for creating headers for the JWT authentication.
+ """
+ token = create_jwt_for_user(user)
+ headers = {'HTTP_AUTHORIZATION': 'JWT ' + token}
+ return headers
+
+ def call_api(self, user, client, data):
+ """ Helper function to call API with data """
+ data = json.dumps(data)
+ headers = self.build_jwt_headers(user)
+ return client.post(self.url, data, content_type='application/json', **headers)
+
+ @ddt.data(
+ [{}, {}],
+ {},
+ [{"test_key": "test_value", "test_key_2": "test_value_2"}]
+ )
+ def test_bad_schema(self, mapping_data):
+ """ Verify the endpoint rejects bad data schema """
+ data = {
+ "username_mappings": mapping_data
+ }
+ response = self.call_api(self.worker, self.worker_client, data)
+ assert response.status_code == 400
+
+ def test_auth(self):
+ """ Verify the endpoint only works with the service worker """
+ data = {
+ "username_mappings": [
+ {"test_username_1": "test_new_username_1"},
+ {"test_username_2": "test_new_username_2"}
+ ]
+ }
+
+ # Test unauthenticated
+ response = self.client.post(self.url, data)
+ assert response.status_code == 403
+
+ # Test non-service worker
+ random_user = UserFactory()
+ response = self.call_api(random_user, APIClient(), data)
+ assert response.status_code == 403
+
+ # Test service worker
+ response = self.call_api(self.worker, self.worker_client, data)
+ assert response.status_code == 200
+
+ def test_basic(self):
+ """ Check successful replacement """
+ data = {
+ "username_mappings": [
+ {self.user.username: self.new_username},
+ ]
+ }
+ expected_response = {
+ 'failed_replacements': [],
+ 'successful_replacements': data["username_mappings"]
+ }
+ self.register_get_username_replacement_response(self.user)
+ response = self.call_api(self.worker, self.worker_client, data)
+ assert response.status_code == 200
+ assert response.data == expected_response
+
+ def test_not_authenticated(self):
+ """
+ Override the parent implementation of this, we JWT auth for this API
+ """
+ pass # lint-amnesty, pylint: disable=unnecessary-pass
+
+
+@ddt.ddt
+@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+class CourseTopicsViewTest(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase):
+ """
+ Tests for CourseTopicsView
+ """
+
+ def setUp(self):
+ httpretty.reset()
+ httpretty.enable()
+ self.addCleanup(httpretty.reset)
+ self.addCleanup(httpretty.disable)
+ super().setUp()
+ self.url = reverse("course_topics", kwargs={"course_id": str(self.course.id)})
+ self.thread_counts_map = {
+ "courseware-1": {"discussion": 2, "question": 3},
+ "courseware-2": {"discussion": 4, "question": 5},
+ "courseware-3": {"discussion": 7, "question": 2},
+ }
+ self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map)
+
+ def create_course(self, blocks_count, module_store, topics):
+ """
+ Create a course in a specified module store with discussion xblocks and topics
+ """
+ course = CourseFactory.create(
+ org="a",
+ course="b",
+ run="c",
+ start=datetime.now(UTC),
+ default_store=module_store,
+ discussion_topics=topics
)
+ CourseEnrollmentFactory.create(user=self.user, course_id=course.id)
+ course_url = reverse("course_topics", kwargs={"course_id": str(course.id)})
+ # add some discussion xblocks
+ for i in range(blocks_count):
+ BlockFactory.create(
+ parent_location=course.location,
+ category='discussion',
+ discussion_id=f'id_module_{i}',
+ discussion_category=f'Category {i}',
+ discussion_target=f'Discussion {i}',
+ publish_item=False,
+ )
+ return course_url, course.id
+
+ def make_discussion_xblock(self, topic_id, category, subcategory, **kwargs):
+ """
+ Build a discussion xblock in self.course
+ """
+ BlockFactory.create(
+ parent_location=self.course.location,
+ category="discussion",
+ discussion_id=topic_id,
+ discussion_category=category,
+ discussion_target=subcategory,
+ **kwargs
+ )
+
+ def test_404(self):
+ response = self.client.get(
+ reverse("course_topics", kwargs={"course_id": "non/existent/course"})
+ )
+ self.assert_response_correct(
+ response,
+ 404,
+ {"developer_message": "Course not found."}
+ )
+
+ def test_basic(self):
+ response = self.client.get(self.url)
+ self.assert_response_correct(
+ response,
+ 200,
+ {
+ "courseware_topics": [],
+ "non_courseware_topics": [{
+ "id": "test_topic",
+ "name": "Test Topic",
+ "children": [],
+ "thread_list_url": 'http://testserver/api/discussion/v1/threads/'
+ '?course_id=course-v1%3Ax%2By%2Bz&topic_id=test_topic',
+ "thread_counts": {"discussion": 0, "question": 0},
+ }],
+ }
+ )
+
+ @ddt.data(
+ (2, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}),
+ (2, ModuleStoreEnum.Type.split, 2,
+ {"Test Topic 1": {"id": "test_topic_1"}, "Test Topic 2": {"id": "test_topic_2"}}),
+ (10, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}),
+ )
+ @ddt.unpack
+ def test_bulk_response(self, blocks_count, module_store, mongo_calls, topics):
+ course_url, course_id = self.create_course(blocks_count, module_store, topics)
+ self.register_get_course_commentable_counts_response(course_id, {})
+ with check_mongo_calls(mongo_calls):
+ with modulestore().default_store(module_store):
+ self.client.get(course_url)
+
+ def test_discussion_topic_404(self):
+ """
+ Tests discussion topic does not exist for the given topic id.
+ """
+ topic_id = "courseware-topic-id"
+ self.make_discussion_xblock(topic_id, "test_category", "test_target")
+ url = f"{self.url}?topic_id=invalid_topic_id"
+ response = self.client.get(url)
+ self.assert_response_correct(
+ response,
+ 404,
+ {"developer_message": "Discussion not found for 'invalid_topic_id'."}
+ )
+
+ def test_topic_id(self):
+ """
+ Tests discussion topic details against a requested topic id
+ """
+ topic_id_1 = "topic_id_1"
+ topic_id_2 = "topic_id_2"
+ self.make_discussion_xblock(topic_id_1, "test_category_1", "test_target_1")
+ self.make_discussion_xblock(topic_id_2, "test_category_2", "test_target_2")
+ url = f"{self.url}?topic_id=topic_id_1,topic_id_2"
+ response = self.client.get(url)
+ self.assert_response_correct(
+ response,
+ 200,
+ {
+ "non_courseware_topics": [],
+ "courseware_topics": [
+ {
+ "children": [{
+ "children": [],
+ "id": "topic_id_1",
+ "thread_list_url": "http://testserver/api/discussion/v1/threads/?"
+ "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1",
+ "name": "test_target_1",
+ "thread_counts": {"discussion": 0, "question": 0},
+ }],
+ "id": None,
+ "thread_list_url": "http://testserver/api/discussion/v1/threads/?"
+ "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1",
+ "name": "test_category_1",
+ "thread_counts": None,
+ },
+ {
+ "children":
+ [{
+ "children": [],
+ "id": "topic_id_2",
+ "thread_list_url": "http://testserver/api/discussion/v1/threads/?"
+ "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2",
+ "name": "test_target_2",
+ "thread_counts": {"discussion": 0, "question": 0},
+ }],
+ "id": None,
+ "thread_list_url": "http://testserver/api/discussion/v1/threads/?"
+ "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2",
+ "name": "test_category_2",
+ "thread_counts": None,
+ }
+ ]
+ }
+ )
+
+ @override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True)
+ def test_new_course_structure_response(self):
+ """
+ Tests whether the new structure is available on old topics API
+ (For mobile compatibility)
+ """
+ chapter = BlockFactory.create(
+ parent_location=self.course.location,
+ category='chapter',
+ display_name="Week 1",
+ start=datetime(2015, 3, 1, tzinfo=UTC),
+ )
+ sequential = BlockFactory.create(
+ parent_location=chapter.location,
+ category='sequential',
+ display_name="Lesson 1",
+ start=datetime(2015, 3, 1, tzinfo=UTC),
+ )
+ BlockFactory.create(
+ parent_location=sequential.location,
+ category='vertical',
+ display_name='vertical',
+ start=datetime(2015, 4, 1, tzinfo=UTC),
+ )
+ DiscussionsConfiguration.objects.create(
+ context_key=self.course.id,
+ provider_type=Provider.OPEN_EDX
+ )
+ update_discussions_settings_from_course_task(str(self.course.id))
+ response = json.loads(self.client.get(self.url).content.decode())
+ keys = ['children', 'id', 'name', 'thread_counts', 'thread_list_url']
+ assert list(response.keys()) == ['courseware_topics', 'non_courseware_topics']
+ assert len(response['courseware_topics']) == 1
+ courseware_keys = list(response['courseware_topics'][0].keys())
+ courseware_keys.sort()
+ assert courseware_keys == keys
+ assert len(response['non_courseware_topics']) == 1
+ non_courseware_keys = list(response['non_courseware_topics'][0].keys())
+ non_courseware_keys.sort()
+ assert non_courseware_keys == keys
+
+
+@ddt.ddt
+@mock.patch('lms.djangoapps.discussion.rest_api.api._get_course', mock.Mock())
+@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+@override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True)
+class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
+ """
+ Tests for CourseTopicsViewV3
+ """
+
+ def setUp(self) -> None:
+ super().setUp()
+ self.password = self.TEST_PASSWORD
+ self.user = UserFactory.create(password=self.password)
+ self.client.login(username=self.user.username, password=self.password)
+ self.staff = AdminFactory.create()
+ self.course = CourseFactory.create(
+ start=datetime(2020, 1, 1),
+ end=datetime(2028, 1, 1),
+ enrollment_start=datetime(2020, 1, 1),
+ enrollment_end=datetime(2028, 1, 1),
+ discussion_topics={"Course Wide Topic": {
+ "id": 'course-wide-topic',
+ "usage_key": None,
+ }}
+ )
+ self.chapter = BlockFactory.create(
+ parent_location=self.course.location,
+ category='chapter',
+ display_name="Week 1",
+ start=datetime(2015, 3, 1, tzinfo=UTC),
+ )
+ self.sequential = BlockFactory.create(
+ parent_location=self.chapter.location,
+ category='sequential',
+ display_name="Lesson 1",
+ start=datetime(2015, 3, 1, tzinfo=UTC),
+ )
+ self.verticals = [
+ BlockFactory.create(
+ parent_location=self.sequential.location,
+ category='vertical',
+ display_name='vertical',
+ start=datetime(2015, 4, 1, tzinfo=UTC),
+ )
+ ]
+ course_key = self.course.id
+ self.config = DiscussionsConfiguration.objects.create(context_key=course_key, provider_type=Provider.OPEN_EDX)
+ topic_links = []
+ update_discussions_settings_from_course_task(str(course_key))
+ topic_id_query = DiscussionTopicLink.objects.filter(context_key=course_key).values_list(
+ 'external_id', flat=True,
+ )
+ topic_ids = list(topic_id_query.order_by('ordering'))
+ DiscussionTopicLink.objects.bulk_create(topic_links)
+ self.topic_stats = {
+ **{topic_id: dict(discussion=random.randint(0, 10), question=random.randint(0, 10))
+ for topic_id in set(topic_ids)},
+ topic_ids[0]: dict(discussion=0, question=0),
+ }
+ patcher = mock.patch(
+ 'lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts',
+ mock.Mock(return_value=self.topic_stats),
+ )
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ self.url = reverse("course_topics_v3", kwargs={"course_id": str(self.course.id)})
+
+ def test_basic(self):
+ response = self.client.get(self.url)
+ data = json.loads(response.content.decode())
+ expected_non_courseware_keys = [
+ 'id', 'usage_key', 'name', 'thread_counts', 'enabled_in_context',
+ 'courseware'
+ ]
+ expected_courseware_keys = [
+ 'id', 'block_id', 'lms_web_url', 'legacy_web_url', 'student_view_url',
+ 'type', 'display_name', 'children', 'courseware'
+ ]
+ assert response.status_code == 200
+ assert len(data) == 2
+ non_courseware_topic_keys = list(data[0].keys())
+ assert non_courseware_topic_keys == expected_non_courseware_keys
+ courseware_topic_keys = list(data[1].keys())
+ assert courseware_topic_keys == expected_courseware_keys
+ expected_courseware_keys.remove('courseware')
+ sequential_keys = list(data[1]['children'][0].keys())
+ assert sequential_keys == (expected_courseware_keys + ['thread_counts'])
+ expected_non_courseware_keys.remove('courseware')
+ vertical_keys = list(data[1]['children'][0]['children'][0].keys())
+ assert vertical_keys == expected_non_courseware_keys
+
+
+@ddt.ddt
+@httpretty.activate
+@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+class LearnerThreadViewAPITest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
+ """Tests for LearnerThreadView list"""
+
+ def setUp(self):
+ """
+ Sets up the test case
+ """
+ super().setUp()
+ self.author = self.user
+ self.remove_keys = [
+ "abuse_flaggers",
+ "body",
+ "children",
+ "commentable_id",
+ "endorsed",
+ "last_activity_at",
+ "resp_total",
+ "thread_type",
+ "user_id",
+ "username",
+ "votes",
+ ]
+ self.replace_keys = [
+ {"from": "unread_comments_count", "to": "unread_comment_count"},
+ {"from": "comments_count", "to": "comment_count"},
+ ]
+ self.add_keys = [
+ {"key": "author", "value": self.author.username},
+ {"key": "abuse_flagged", "value": False},
+ {"key": "author_label", "value": None},
+ {"key": "can_delete", "value": True},
+ {"key": "close_reason", "value": None},
+ {
+ "key": "comment_list_url",
+ "value": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread"
+ },
+ {
+ "key": "editable_fields",
+ "value": [
+ 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body',
+ 'read', 'title', 'topic_id', 'type'
+ ]
+ },
+ {"key": "endorsed_comment_list_url", "value": None},
+ {"key": "following", "value": False},
+ {"key": "group_name", "value": None},
+ {"key": "has_endorsed", "value": False},
+ {"key": "last_edit", "value": None},
+ {"key": "non_endorsed_comment_list_url", "value": None},
+ {"key": "preview_body", "value": "Test body"},
+ {"key": "raw_body", "value": "Test body"},
+
+ {"key": "rendered_body", "value": "Test body
"},
+ {"key": "response_count", "value": 0},
+ {"key": "topic_id", "value": "test_topic"},
+ {"key": "type", "value": "discussion"},
+ {"key": "users", "value": {
+ self.user.username: {
+ "profile": {
+ "image": {
+ "has_image": False,
+ "image_url_full": "http://testserver/static/default_500.png",
+ "image_url_large": "http://testserver/static/default_120.png",
+ "image_url_medium": "http://testserver/static/default_50.png",
+ "image_url_small": "http://testserver/static/default_30.png",
+ }
+ }
+ }
+ }},
+ {"key": "vote_count", "value": 4},
+ {"key": "voted", "value": False},
+
+ ]
+ self.url = reverse("discussion_learner_threads", kwargs={'course_id': str(self.course.id)})
+
+ def update_thread(self, thread):
+ """
+ This function updates the thread by adding and remove some keys.
+ Value of these keys has been defined in setUp function
+ """
+ for element in self.add_keys:
+ thread[element['key']] = element['value']
+ for pair in self.replace_keys:
+ thread[pair['to']] = thread.pop(pair['from'])
+ for key in self.remove_keys:
+ thread.pop(key)
+ thread['comment_count'] += 1
+ return thread
+
+ def test_basic(self):
+ """
+ Tests the data is fetched correctly
+
+ Note: test_basic is required as the name because DiscussionAPIViewTestMixin
+ calls this test case automatically
+ """
+ self.register_get_user_response(self.user)
+ expected_cs_comments_response = {
+ "collection": [make_minimal_cs_thread({
+ "id": "test_thread",
+ "course_id": str(self.course.id),
+ "commentable_id": "test_topic",
+ "user_id": str(self.user.id),
+ "username": self.user.username,
+ "created_at": "2015-04-28T00:00:00Z",
+ "updated_at": "2015-04-28T11:11:11Z",
+ "title": "Test Title",
+ "body": "Test body",
+ "votes": {"up_count": 4},
+ "comments_count": 5,
+ "unread_comments_count": 3,
+ "closed_by_label": None,
+ "edit_by_label": None,
+ })],
+ "page": 1,
+ "num_pages": 1,
+ }
+ self.register_user_active_threads(self.user.id, expected_cs_comments_response)
+ self.url += f"?username={self.user.username}"
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ response_data = json.loads(response.content.decode('utf-8'))
+ expected_api_response = expected_cs_comments_response['collection']
+
+ for thread in expected_api_response:
+ self.update_thread(thread)
+
+ assert response_data['results'] == expected_api_response
+ assert response_data['pagination'] == {
+ "next": None,
+ "previous": None,
+ "count": 1,
+ "num_pages": 1,
+ }
+
+ def test_no_username_given(self):
+ """
+ Tests that 404 response is returned when no username is passed
+ """
+ response = self.client.get(self.url)
+ assert response.status_code == 404
+
+ def test_not_authenticated(self):
+ """
+ This test is called by DiscussionAPIViewTestMixin and is not required in
+ our case
+ """
+ assert True
+
+ @ddt.data("None", "discussion", "question")
+ def test_thread_type_by(self, thread_type):
+ """
+ Tests the thread_type parameter
+
+ Arguments:
+ thread_type (str): Value of thread_type can be 'None',
+ 'discussion' and 'question'
+ """
+ threads = [make_minimal_cs_thread({
+ "id": "test_thread",
+ "course_id": str(self.course.id),
+ "commentable_id": "test_topic",
+ "user_id": str(self.user.id),
+ "username": self.user.username,
+ "created_at": "2015-04-28T00:00:00Z",
+ "updated_at": "2015-04-28T11:11:11Z",
+ "title": "Test Title",
+ "body": "Test body",
+ "votes": {"up_count": 4},
+ "comments_count": 5,
+ "unread_comments_count": 3,
+ })]
+ expected_cs_comments_response = {
+ "collection": threads,
+ "page": 1,
+ "num_pages": 1,
+ }
+ self.register_get_user_response(self.user)
+ self.register_user_active_threads(self.user.id, expected_cs_comments_response)
+ response = self.client.get(
+ self.url,
+ {
+ "course_id": str(self.course.id),
+ "username": self.user.username,
+ "thread_type": thread_type,
+ }
+ )
+ assert response.status_code == 200
+ params = {
+ "user_id": str(self.user.id),
+ "course_id": str(self.course.id),
+ "page": 1,
+ "per_page": 10,
+ "thread_type": thread_type,
+ "sort_key": 'activity',
+ "count_flagged": False
+ }
+
+ self.check_mock_called_with("get_user_active_threads", -1, **params)
+
+ @ddt.data(
+ ("last_activity_at", "activity"),
+ ("comment_count", "comments"),
+ ("vote_count", "votes")
+ )
+ @ddt.unpack
+ def test_order_by(self, http_query, cc_query):
+ """
+ Tests the order_by parameter for active threads
+
+ Arguments:
+ http_query (str): Query string sent in the http request
+ cc_query (str): Query string used for the comments client service
+ """
+ threads = [make_minimal_cs_thread({
+ "id": "test_thread",
+ "course_id": str(self.course.id),
+ "commentable_id": "test_topic",
+ "user_id": str(self.user.id),
+ "username": self.user.username,
+ "created_at": "2015-04-28T00:00:00Z",
+ "updated_at": "2015-04-28T11:11:11Z",
+ "title": "Test Title",
+ "body": "Test body",
+ "votes": {"up_count": 4},
+ "comments_count": 5,
+ "unread_comments_count": 3,
+ })]
+ expected_cs_comments_response = {
+ "collection": threads,
+ "page": 1,
+ "num_pages": 1,
+ }
+ self.register_get_user_response(self.user)
+ self.register_user_active_threads(self.user.id, expected_cs_comments_response)
+ response = self.client.get(
+ self.url,
+ {
+ "course_id": str(self.course.id),
+ "username": self.user.username,
+ "order_by": http_query,
+ }
+ )
+ assert response.status_code == 200
+ params = {
+ "user_id": str(self.user.id),
+ "course_id": str(self.course.id),
+ "page": 1,
+ "per_page": 10,
+ "sort_key": cc_query,
+ "count_flagged": False
+ }
+ self.check_mock_called_with("get_user_active_threads", -1, **params)
+
+ @ddt.data("flagged", "unanswered", "unread", "unresponded")
+ def test_status_by(self, post_status):
+ """
+ Tests the post_status parameter
+
+ Arguments:
+ post_status (str): Value of post_status can be 'flagged',
+ 'unanswered' and 'unread'
+ """
+ threads = [make_minimal_cs_thread({
+ "id": "test_thread",
+ "course_id": str(self.course.id),
+ "commentable_id": "test_topic",
+ "user_id": str(self.user.id),
+ "username": self.user.username,
+ "created_at": "2015-04-28T00:00:00Z",
+ "updated_at": "2015-04-28T11:11:11Z",
+ "title": "Test Title",
+ "body": "Test body",
+ "votes": {"up_count": 4},
+ "comments_count": 5,
+ "unread_comments_count": 3,
+ })]
+ expected_cs_comments_response = {
+ "collection": threads,
+ "page": 1,
+ "num_pages": 1,
+ }
+ self.register_get_user_response(self.user)
+ self.register_user_active_threads(self.user.id, expected_cs_comments_response)
+ response = self.client.get(
+ self.url,
+ {
+ "course_id": str(self.course.id),
+ "username": self.user.username,
+ "status": post_status,
+ }
+ )
+ if post_status == "flagged":
+ assert response.status_code == 403
+ else:
+ assert response.status_code == 200
+ params = {
+ "user_id": str(self.user.id),
+ "course_id": str(self.course.id),
+ "page": 1,
+ "per_page": 10,
+ post_status: True,
+ "sort_key": 'activity',
+ "count_flagged": False
+ }
+ self.check_mock_called_with("get_user_active_threads", -1, **params)
+
+
+@ddt.ddt
+@httpretty.activate
+@override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True)
+class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, ForumMockUtilsMixin, APITestCase,
+ SharedModuleStoreTestCase):
+ """
+ Tests for the course stats endpoint
+ """
+
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def setUp(self) -> None:
+ super().setUp()
+ self.course = CourseFactory.create()
+ self.course_key = str(self.course.id)
+ seed_permissions_roles(self.course.id)
+ self.user = UserFactory(username='user')
+ self.moderator = UserFactory(username='moderator')
+ moderator_role = Role.objects.get(name="Moderator", course_id=self.course.id)
+ moderator_role.users.add(self.moderator)
+ self.stats = [
+ {
+ "active_flags": random.randint(0, 3),
+ "inactive_flags": random.randint(0, 2),
+ "replies": random.randint(0, 30),
+ "responses": random.randint(0, 100),
+ "threads": random.randint(0, 10),
+ "username": f"user-{idx}"
+ }
+ for idx in range(10)
+ ]
+
+ for stat in self.stats:
+ user = UserFactory.create(
+ username=stat['username'],
+ email=f"{stat['username']}@example.com",
+ password=self.TEST_PASSWORD
+ )
+ CourseEnrollment.enroll(user, self.course.id, mode='audit')
+
+ CourseEnrollment.enroll(self.moderator, self.course.id, mode='audit')
+ self.stats_without_flags = [{**stat, "active_flags": None, "inactive_flags": None} for stat in self.stats]
+ self.register_course_stats_response(self.course_key, self.stats, 1, 3)
+ self.url = reverse("discussion_course_activity_stats", kwargs={"course_key_string": self.course_key})
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ super().setUpClassAndForumMock()
+
+ @classmethod
+ def tearDownClass(cls):
+ super().tearDownClass()
+ super().disposeForumMocks()
+
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def test_regular_user(self):
+ """
+ Tests that for a regular user stats are returned without flag counts
+ """
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
+ response = self.client.get(self.url)
+ data = response.json()
+ assert data["results"] == self.stats_without_flags
+
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def test_moderator_user(self):
+ """
+ Tests that for a moderator user stats are returned with flag counts
+ """
+ self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
+ response = self.client.get(self.url)
+ data = response.json()
+ assert data["results"] == self.stats
+
+ @ddt.data(
+ ("moderator", "flagged", "flagged"),
+ ("moderator", "activity", "activity"),
+ ("moderator", "recency", "recency"),
+ ("moderator", None, "flagged"),
+ ("user", None, "activity"),
+ ("user", "activity", "activity"),
+ ("user", "recency", "recency"),
+ )
+ @ddt.unpack
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def test_sorting(self, username, ordering_requested, ordering_performed):
+ """
+ Test valid sorting options and defaults
+ """
+ self.client.login(username=username, password=self.TEST_PASSWORD)
+ params = {}
+ if ordering_requested:
+ params = {"order_by": ordering_requested}
+ self.client.get(self.url, params)
+ self.check_mock_called("get_user_course_stats")
+ params = self.get_mock_func_calls("get_user_course_stats")[-1][1]
+ assert params["sort_key"] == ordering_performed
+
+ @ddt.data("flagged", "xyz")
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def test_sorting_error_regular_user(self, order_by):
+ """
+ Test for invalid sorting options for regular users.
+ """
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
+ response = self.client.get(self.url, {"order_by": order_by})
+ assert "order_by" in response.json()["field_errors"]
+
+ @ddt.data(
+ ('user', 'user-0,user-1,user-2,user-3,user-4,user-5,user-6,user-7,user-8,user-9'),
+ ('moderator', 'moderator'),
+ )
+ @ddt.unpack
+ @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
+ def test_with_username_param(self, username_search_string, comma_separated_usernames):
+ """
+ Test for endpoint with username param.
+ """
+ params = {'username': username_search_string}
+ self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
+ self.client.get(self.url, params)
+ self.check_mock_called("get_user_course_stats")
+ params = self.get_mock_func_calls("get_user_course_stats")[-1][1]
+ assert params["usernames"] == comma_separated_usernames
+
+ @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
+ def test_with_username_param_with_no_matches(self):
+ """
+ Test for endpoint with username param with no matches.
+ """
+ params = {'username': 'unknown'}
+ self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
+ response = self.client.get(self.url, params)
+ data = response.json()
+ self.assertFalse(data['results'])
+ assert data['pagination']['count'] == 0
+
+ @ddt.data(
+ 'user-0',
+ 'USER-1',
+ 'User-2',
+ 'UsEr-3'
+ )
+ @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
+ def test_with_username_param_case(self, username_search_string):
+ """
+ Test user search function is case-insensitive.
+ """
+ response = get_usernames_from_search_string(self.course_key, username_search_string, 1, 1)
+ assert response == (username_search_string.lower(), 1, 1)
+
+
+@httpretty.activate
+@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+class RetireViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
+ """Tests for CourseView"""
+
+ def setUp(self):
+ super().setUp()
+ RetirementState.objects.create(state_name='PENDING', state_execution_order=1)
+ self.retire_forums_state = RetirementState.objects.create(state_name='RETIRE_FORUMS', state_execution_order=11)
+
+ self.retirement = UserRetirementStatus.create_retirement(self.user)
+ self.retirement.current_state = self.retire_forums_state
+ self.retirement.save()
+
+ self.superuser = SuperuserFactory()
+ self.superuser_client = APIClient()
+ self.retired_username = get_retired_username_by_username(self.user.username)
+ self.url = reverse("retire_discussion_user")
+
+ def assert_response_correct(self, response, expected_status, expected_content):
+ """
+ Assert that the response has the given status code and content
+ """
+ assert response.status_code == expected_status
+
+ if expected_content:
+ assert response.content.decode('utf-8') == expected_content
+
+ def build_jwt_headers(self, user):
+ """
+ Helper function for creating headers for the JWT authentication.
+ """
+ token = create_jwt_for_user(user)
+ headers = {'HTTP_AUTHORIZATION': 'JWT ' + token}
+ return headers
+
+ def test_basic(self):
+ """
+ Check successful retirement case
+ """
+ self.register_get_user_retire_response(self.user)
+ headers = self.build_jwt_headers(self.superuser)
+ data = {'username': self.user.username}
+ response = self.superuser_client.post(self.url, data, **headers)
+ self.assert_response_correct(response, 204, b"")
+
+ def test_nonexistent_user(self):
+ """
+ Check that we handle unknown users appropriately
+ """
+ nonexistent_username = "nonexistent user"
+ self.retired_username = get_retired_username_by_username(nonexistent_username)
+ data = {'username': nonexistent_username}
+ headers = self.build_jwt_headers(self.superuser)
+ response = self.superuser_client.post(self.url, data, **headers)
+ self.assert_response_correct(response, 404, None)
+
+ def test_not_authenticated(self):
+ """
+ Override the parent implementation of this, we JWT auth for this API
+ """
+ pass # lint-amnesty, pylint: disable=unnecessary-pass
+
+
+@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+class UploadFileViewTest(ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin, ModuleStoreTestCase):
+ """
+ Tests for UploadFileView.
+ """
+
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def setUp(self):
+ super().setUp()
+ self.valid_file = {
+ "uploaded_file": SimpleUploadedFile(
+ "test.jpg",
+ b"test content",
+ content_type="image/jpeg",
+ ),
+ }
+ self.user = UserFactory.create(password=self.TEST_PASSWORD)
+ self.course = CourseFactory.create(org='a', course='b', run='c', start=datetime.now(UTC))
+ self.url = reverse("upload_file", kwargs={"course_id": str(self.course.id)})
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ super().setUpClassAndForumMock()
+
+ @classmethod
+ def tearDownClass(cls):
+ super().tearDownClass()
+ super().disposeForumMocks()
+
+ def user_login(self):
+ """
+ Authenticates the test client with the example user.
+ """
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
+
+ def enroll_user_in_course(self):
+ """
+ Makes the example user enrolled to the course.
+ """
+ CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
+
+ def assert_upload_success(self, response):
+ """
+ Asserts that the upload response was successful and returned the
+ expected contents.
+ """
+ assert response.status_code == status.HTTP_200_OK
+ assert response.content_type == "application/json"
+ response_data = json.loads(response.content)
+ assert "location" in response_data
+
+ def test_file_upload_by_unauthenticated_user(self):
+ """
+ Should fail if an unauthenticated user tries to upload a file.
+ """
+ response = self.client.post(self.url, self.valid_file)
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+ def test_file_upload_by_unauthorized_user(self):
+ """
+ Should fail if the user is not either staff or a student
+ enrolled in the course.
+ """
+ self.user_login()
+ response = self.client.post(self.url, self.valid_file)
assert response.status_code == status.HTTP_403_FORBIDDEN
- thread_mock.count_documents.assert_not_called()
- comment_mock.count_documents.assert_not_called()
- @ddt.data(FORUM_ROLE_MODERATOR, FORUM_ROLE_ADMINISTRATOR)
- def test_bulk_delete_allowed_for_discussion_roles(self, role):
+ def test_file_upload_by_enrolled_user(self):
"""
- Test bulk delete user posts passed with discussion roles.
+ Should succeed when a valid file is uploaded by an authenticated
+ user who's enrolled in the course.
"""
- self.mock_comment_and_thread_count(comment_count=1, thread_count=1)
- assign_role(self.course.id, self.user, role)
- response = self.client.post(
- f"{self.url}?username={self.user2.username}",
- format="json",
- )
- assert response.status_code == status.HTTP_202_ACCEPTED
- assert response.json() == {"comment_count": 1, "thread_count": 1}
+ self.user_login()
+ self.enroll_user_in_course()
+ response = self.client.post(self.url, self.valid_file)
+ self.assert_upload_success(response)
- @mock.patch('lms.djangoapps.discussion.rest_api.views.delete_course_post_for_user.apply_async')
- @ddt.data(True, False)
- def test_task_only_runs_if_execute_param_is_true(self, execute, task_mock):
+ def test_file_upload_by_global_staff(self):
"""
- Test bulk delete user posts task runs only if execute parameter is set to true.
+ Should succeed when a valid file is uploaded by a global staff
+ member.
"""
- assign_role(self.course.id, self.user, FORUM_ROLE_MODERATOR)
- self.mock_comment_and_thread_count(comment_count=1, thread_count=1)
- response = self.client.post(
- f"{self.url}?username={self.user2.username}&execute={str(execute).lower()}",
- format="json",
+ self.user_login()
+ GlobalStaff().add_users(self.user)
+ response = self.client.post(self.url, self.valid_file)
+ self.assert_upload_success(response)
+
+ def test_file_upload_by_instructor(self):
+ """
+ Should succeed when a valid file is uploaded by a course instructor.
+ """
+ self.user_login()
+ CourseInstructorRole(course_key=self.course.id).add_users(self.user)
+ response = self.client.post(self.url, self.valid_file)
+ self.assert_upload_success(response)
+
+ def test_file_upload_by_course_staff(self):
+ """
+ Should succeed when a valid file is uploaded by a course staff
+ member.
+ """
+ self.user_login()
+ CourseStaffRole(course_key=self.course.id).add_users(self.user)
+ response = self.client.post(self.url, self.valid_file)
+ self.assert_upload_success(response)
+
+ def test_file_upload_with_thread_key(self):
+ """
+ Should contain the given thread_key in the uploaded file name.
+ """
+ self.user_login()
+ self.enroll_user_in_course()
+ response = self.client.post(self.url, {
+ **self.valid_file,
+ "thread_key": "somethread",
+ })
+ response_data = json.loads(response.content)
+ assert "/somethread/" in response_data["location"]
+
+ def test_file_upload_with_invalid_file(self):
+ """
+ Should fail if the uploaded file format is not allowed.
+ """
+ self.user_login()
+ self.enroll_user_in_course()
+ invalid_file = {
+ "uploaded_file": SimpleUploadedFile(
+ "test.txt",
+ b"test content",
+ content_type="text/plain",
+ ),
+ }
+ response = self.client.post(self.url, invalid_file)
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_file_upload_with_invalid_course_id(self):
+ """
+ Should fail if the course does not exist.
+ """
+ self.user_login()
+ self.enroll_user_in_course()
+ url = reverse("upload_file", kwargs={"course_id": "d/e/f"})
+ response = self.client.post(url, self.valid_file)
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_file_upload_with_no_data(self):
+ """
+ Should fail when the user sends a request missing an
+ `uploaded_file` field.
+ """
+ self.user_login()
+ self.enroll_user_in_course()
+ response = self.client.post(self.url, data={})
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+
+@ddt.ddt
+class CourseDiscussionSettingsAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTestCase):
+ """
+ Test the course discussion settings handler API endpoint.
+ """
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def setUp(self):
+ super().setUp()
+ self.course = CourseFactory.create(
+ org="x",
+ course="y",
+ run="z",
+ start=datetime.now(UTC),
+ discussion_topics={"Test Topic": {"id": "test_topic"}}
)
- assert response.status_code == status.HTTP_202_ACCEPTED
- assert response.json() == {"comment_count": 1, "thread_count": 1}
- assert task_mock.called is execute
+ self.path = reverse('discussion_course_settings', kwargs={'course_id': str(self.course.id)})
+ self.password = self.TEST_PASSWORD
+ self.user = UserFactory(username='staff', password=self.password, is_staff=True)
+
+ def _get_oauth_headers(self, user):
+ """Return the OAuth headers for testing OAuth authentication"""
+ access_token = AccessTokenFactory.create(user=user, application=ApplicationFactory()).token
+ headers = {
+ 'HTTP_AUTHORIZATION': 'Bearer ' + access_token
+ }
+ return headers
+
+ def _login_as_staff(self):
+ """Log the client in as the staff."""
+ self.client.login(username=self.user.username, password=self.password)
+
+ def _login_as_discussion_staff(self):
+ user = UserFactory(username='abc', password='abc')
+ role = Role.objects.create(name='Administrator', course_id=self.course.id)
+ role.users.set([user])
+ self.client.login(username=user.username, password='abc')
+
+ def _create_divided_discussions(self):
+ """Create some divided discussions for testing."""
+ divided_inline_discussions = ['Topic A', ]
+ divided_course_wide_discussions = ['Topic B', ]
+ divided_discussions = divided_inline_discussions + divided_course_wide_discussions
+
+ BlockFactory.create(
+ parent=self.course,
+ category='discussion',
+ discussion_id=topic_name_to_id(self.course, 'Topic A'),
+ discussion_category='Chapter',
+ discussion_target='Discussion',
+ start=datetime.now()
+ )
+ discussion_topics = {
+ "Topic B": {"id": "Topic B"},
+ }
+ config_course_cohorts(self.course, is_cohorted=True)
+ config_course_discussions(
+ self.course,
+ discussion_topics=discussion_topics,
+ divided_discussions=divided_discussions
+ )
+ return divided_inline_discussions, divided_course_wide_discussions
+
+ def _get_expected_response(self):
+ """Return the default expected response before any changes to the discussion settings."""
+ return {
+ 'always_divide_inline_discussions': False,
+ 'divided_inline_discussions': [],
+ 'divided_course_wide_discussions': [],
+ 'id': 1,
+ 'division_scheme': 'cohort',
+ 'available_division_schemes': ['cohort'],
+ 'reported_content_email_notifications': False,
+ }
+
+ def patch_request(self, data, headers=None):
+ headers = headers if headers else {}
+ return self.client.patch(self.path, json.dumps(data), content_type='application/merge-patch+json', **headers)
+
+ def _assert_current_settings(self, expected_response):
+ """Validate the current discussion settings against the expected response."""
+ response = self.client.get(self.path)
+ assert response.status_code == 200
+ content = json.loads(response.content.decode('utf-8'))
+ assert content == expected_response
+
+ def _assert_patched_settings(self, data, expected_response):
+ """Validate the patched settings against the expected response."""
+ response = self.patch_request(data)
+ assert response.status_code == 204
+ self._assert_current_settings(expected_response)
+
+ @ddt.data('get', 'patch')
+ def test_authentication_required(self, method):
+ """Test and verify that authentication is required for this endpoint."""
+ self.client.logout()
+ response = getattr(self.client, method)(self.path)
+ assert response.status_code == 401
+
+ @ddt.data(
+ {'is_staff': False, 'get_status': 403, 'put_status': 403},
+ {'is_staff': True, 'get_status': 200, 'put_status': 204},
+ )
+ @ddt.unpack
+ def test_oauth(self, is_staff, get_status, put_status):
+ """Test that OAuth authentication works for this endpoint."""
+ user = UserFactory(is_staff=is_staff)
+ headers = self._get_oauth_headers(user)
+ self.client.logout()
+
+ response = self.client.get(self.path, **headers)
+ assert response.status_code == get_status
+
+ response = self.patch_request(
+ {'always_divide_inline_discussions': True}, headers
+ )
+ assert response.status_code == put_status
+
+ def test_non_existent_course_id(self):
+ """Test the response when this endpoint is passed a non-existent course id."""
+ self._login_as_staff()
+ response = self.client.get(
+ reverse('discussion_course_settings', kwargs={
+ 'course_id': 'course-v1:a+b+c'
+ })
+ )
+ assert response.status_code == 404
+
+ def test_patch_request_by_discussion_staff(self):
+ """Test the response when patch request is sent by a user with discussions staff role."""
+ self._login_as_discussion_staff()
+ response = self.patch_request(
+ {'always_divide_inline_discussions': True}
+ )
+ assert response.status_code == 403
+
+ def test_get_request_by_discussion_staff(self):
+ """Test the response when get request is sent by a user with discussions staff role."""
+ self._login_as_discussion_staff()
+ divided_inline_discussions, divided_course_wide_discussions = self._create_divided_discussions()
+ response = self.client.get(self.path)
+ assert response.status_code == 200
+ expected_response = self._get_expected_response()
+ expected_response['divided_course_wide_discussions'] = [
+ topic_name_to_id(self.course, name) for name in divided_course_wide_discussions
+ ]
+ expected_response['divided_inline_discussions'] = [
+ topic_name_to_id(self.course, name) for name in divided_inline_discussions
+ ]
+ content = json.loads(response.content.decode('utf-8'))
+ assert content == expected_response
+
+ def test_get_request_by_non_staff_user(self):
+ """Test the response when get request is sent by a regular user with no staff role."""
+ user = UserFactory(username='abc', password='abc')
+ self.client.login(username=user.username, password='abc')
+ response = self.client.get(self.path)
+ assert response.status_code == 403
+
+ def test_patch_request_by_non_staff_user(self):
+ """Test the response when patch request is sent by a regular user with no staff role."""
+ user = UserFactory(username='abc', password='abc')
+ self.client.login(username=user.username, password='abc')
+ response = self.patch_request(
+ {'always_divide_inline_discussions': True}
+ )
+ assert response.status_code == 403
+
+ def test_get_settings(self):
+ """Test the current discussion settings against the expected response."""
+ divided_inline_discussions, divided_course_wide_discussions = self._create_divided_discussions()
+ self._login_as_staff()
+ response = self.client.get(self.path)
+ assert response.status_code == 200
+ expected_response = self._get_expected_response()
+ expected_response['divided_course_wide_discussions'] = [
+ topic_name_to_id(self.course, name) for name in divided_course_wide_discussions
+ ]
+ expected_response['divided_inline_discussions'] = [
+ topic_name_to_id(self.course, name) for name in divided_inline_discussions
+ ]
+ content = json.loads(response.content.decode('utf-8'))
+ assert content == expected_response
+
+ def test_available_schemes(self):
+ """Test the available division schemes against the expected response."""
+ config_course_cohorts(self.course, is_cohorted=False)
+ self._login_as_staff()
+ expected_response = self._get_expected_response()
+ expected_response['available_division_schemes'] = []
+ self._assert_current_settings(expected_response)
+
+ CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT)
+ CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED)
+
+ expected_response['available_division_schemes'] = [CourseDiscussionSettings.ENROLLMENT_TRACK]
+ self._assert_current_settings(expected_response)
+
+ config_course_cohorts(self.course, is_cohorted=True)
+ expected_response['available_division_schemes'] = [
+ CourseDiscussionSettings.COHORT, CourseDiscussionSettings.ENROLLMENT_TRACK
+ ]
+ self._assert_current_settings(expected_response)
+
+ def test_empty_body_patch_request(self):
+ """Test the response status code on sending a PATCH request with an empty body or missing fields."""
+ self._login_as_staff()
+ response = self.patch_request("")
+ assert response.status_code == 400
+
+ response = self.patch_request({})
+ assert response.status_code == 400
+
+ @ddt.data(
+ {'abc': 123},
+ {'divided_course_wide_discussions': 3},
+ {'divided_inline_discussions': 'a'},
+ {'always_divide_inline_discussions': ['a']},
+ {'division_scheme': True}
+ )
+ def test_invalid_body_parameters(self, body):
+ """Test the response status code on sending a PATCH request with parameters having incorrect types."""
+ self._login_as_staff()
+ response = self.patch_request(body)
+ assert response.status_code == 400
+
+ def test_update_always_divide_inline_discussion_settings(self):
+ """Test whether the 'always_divide_inline_discussions' setting is updated."""
+ config_course_cohorts(self.course, is_cohorted=True)
+ self._login_as_staff()
+ expected_response = self._get_expected_response()
+ self._assert_current_settings(expected_response)
+ expected_response['always_divide_inline_discussions'] = True
+
+ self._assert_patched_settings({'always_divide_inline_discussions': True}, expected_response)
+
+ def test_update_course_wide_discussion_settings(self):
+ """Test whether the 'divided_course_wide_discussions' setting is updated."""
+ discussion_topics = {
+ 'Topic B': {'id': 'Topic B'}
+ }
+ config_course_cohorts(self.course, is_cohorted=True)
+ config_course_discussions(self.course, discussion_topics=discussion_topics)
+ expected_response = self._get_expected_response()
+ self._login_as_staff()
+ self._assert_current_settings(expected_response)
+ expected_response['divided_course_wide_discussions'] = [
+ topic_name_to_id(self.course, "Topic B")
+ ]
+ self._assert_patched_settings(
+ {'divided_course_wide_discussions': [topic_name_to_id(self.course, "Topic B")]},
+ expected_response
+ )
+ expected_response['divided_course_wide_discussions'] = []
+ self._assert_patched_settings(
+ {'divided_course_wide_discussions': []},
+ expected_response
+ )
+
+ def test_update_inline_discussion_settings(self):
+ """Test whether the 'divided_inline_discussions' setting is updated."""
+ config_course_cohorts(self.course, is_cohorted=True)
+ self._login_as_staff()
+ expected_response = self._get_expected_response()
+ self._assert_current_settings(expected_response)
+
+ now = datetime.now()
+ BlockFactory.create(
+ parent_location=self.course.location,
+ category='discussion',
+ discussion_id='Topic_A',
+ discussion_category='Chapter',
+ discussion_target='Discussion',
+ start=now
+ )
+ expected_response['divided_inline_discussions'] = ['Topic_A', ]
+ self._assert_patched_settings({'divided_inline_discussions': ['Topic_A']}, expected_response)
+
+ expected_response['divided_inline_discussions'] = []
+ self._assert_patched_settings({'divided_inline_discussions': []}, expected_response)
+
+ def test_update_division_scheme(self):
+ """Test whether the 'division_scheme' setting is updated."""
+ config_course_cohorts(self.course, is_cohorted=True)
+ self._login_as_staff()
+ expected_response = self._get_expected_response()
+ self._assert_current_settings(expected_response)
+ expected_response['division_scheme'] = 'none'
+ self._assert_patched_settings({'division_scheme': 'none'}, expected_response)
+
+ def test_update_reported_content_email_notifications(self):
+ """Test whether the 'reported_content_email_notifications' setting is updated."""
+ config_course_cohorts(self.course, is_cohorted=True)
+ config_course_discussions(self.course, reported_content_email_notifications=True)
+ expected_response = self._get_expected_response()
+ expected_response['reported_content_email_notifications'] = True
+ self._login_as_staff()
+ self._assert_current_settings(expected_response)
+
+
+@ddt.ddt
+class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTestCase):
+ """
+ Test the course discussion roles management endpoint.
+ """
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def setUp(self):
+ super().setUp()
+ self.course = CourseFactory.create(
+ org="x",
+ course="y",
+ run="z",
+ start=datetime.now(UTC),
+ )
+ self.password = self.TEST_PASSWORD
+ self.user = UserFactory(username='staff', password=self.password, is_staff=True)
+ course_key = CourseKey.from_string('course-v1:x+y+z')
+ seed_permissions_roles(course_key)
+
+ @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+ def path(self, course_id=None, role=None):
+ """Return the URL path to the endpoint based on the provided arguments."""
+ course_id = str(self.course.id) if course_id is None else course_id
+ role = 'Moderator' if role is None else role
+ return reverse(
+ 'discussion_course_roles',
+ kwargs={'course_id': course_id, 'rolename': role}
+ )
+
+ def _get_oauth_headers(self, user):
+ """Return the OAuth headers for testing OAuth authentication."""
+ access_token = AccessTokenFactory.create(user=user, application=ApplicationFactory()).token
+ headers = {
+ 'HTTP_AUTHORIZATION': 'Bearer ' + access_token
+ }
+ return headers
+
+ def _login_as_staff(self):
+ """Log the client is as the staff user."""
+ self.client.login(username=self.user.username, password=self.password)
+
+ def _create_and_enroll_users(self, count):
+ """Create 'count' number of users and enroll them in self.course."""
+ users = []
+ for _ in range(count):
+ user = UserFactory()
+ CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
+ users.append(user)
+ return users
+
+ def _add_users_to_role(self, users, rolename):
+ """Add the given users to the given role."""
+ role = Role.objects.get(name=rolename, course_id=self.course.id)
+ for user in users:
+ role.users.add(user)
+
+ def post(self, role, user_id, action):
+ """Make a POST request to the endpoint using the provided parameters."""
+ self._login_as_staff()
+ return self.client.post(self.path(role=role), {'user_id': user_id, 'action': action})
+
+ @ddt.data('get', 'post')
+ def test_authentication_required(self, method):
+ """Test and verify that authentication is required for this endpoint."""
+ self.client.logout()
+ response = getattr(self.client, method)(self.path())
+ assert response.status_code == 401
+
+ def test_oauth(self):
+ """Test that OAuth authentication works for this endpoint."""
+ oauth_headers = self._get_oauth_headers(self.user)
+ self.client.logout()
+ response = self.client.get(self.path(), **oauth_headers)
+ assert response.status_code == 200
+ body = {'user_id': 'staff', 'action': 'allow'}
+ response = self.client.post(self.path(), body, format='json', **oauth_headers)
+ assert response.status_code == 200
+
+ @ddt.data(
+ {'username': 'u1', 'is_staff': False, 'expected_status': 403},
+ {'username': 'u2', 'is_staff': True, 'expected_status': 200},
+ )
+ @ddt.unpack
+ def test_staff_permission_required(self, username, is_staff, expected_status):
+ """Test and verify that only users with staff permission can access this endpoint."""
+ UserFactory(username=username, password='edx', is_staff=is_staff)
+ self.client.login(username=username, password='edx')
+ response = self.client.get(self.path())
+ assert response.status_code == expected_status
+
+ response = self.client.post(self.path(), {'user_id': username, 'action': 'allow'}, format='json')
+ assert response.status_code == expected_status
+
+ def test_non_existent_course_id(self):
+ """Test the response when the endpoint URL contains a non-existent course id."""
+ self._login_as_staff()
+ path = self.path(course_id='course-v1:a+b+c')
+ response = self.client.get(path)
+
+ assert response.status_code == 404
+
+ response = self.client.post(path)
+ assert response.status_code == 404
+
+ def test_non_existent_course_role(self):
+ """Test the response when the endpoint URL contains a non-existent role."""
+ self._login_as_staff()
+ path = self.path(role='A')
+ response = self.client.get(path)
+
+ assert response.status_code == 400
+
+ response = self.client.post(path)
+ assert response.status_code == 400
+
+ @ddt.data(
+ {'role': 'Moderator', 'count': 0},
+ {'role': 'Moderator', 'count': 1},
+ {'role': 'Group Moderator', 'count': 2},
+ {'role': 'Community TA', 'count': 3},
+ )
+ @ddt.unpack
+ def test_get_role_members(self, role, count):
+ """Test the get role members endpoint response."""
+ config_course_cohorts(self.course, is_cohorted=True)
+ users = self._create_and_enroll_users(count=count)
+
+ self._add_users_to_role(users, role)
+ self._login_as_staff()
+ response = self.client.get(self.path(role=role))
+
+ assert response.status_code == 200
+
+ content = json.loads(response.content.decode('utf-8'))
+ assert content['course_id'] == 'course-v1:x+y+z'
+ assert len(content['results']) == count
+ expected_fields = ('username', 'email', 'first_name', 'last_name', 'group_name')
+ for item in content['results']:
+ for expected_field in expected_fields:
+ assert expected_field in item
+ assert content['division_scheme'] == 'cohort'
+
+ def test_post_missing_body(self):
+ """Test the response with a POST request without a body."""
+ self._login_as_staff()
+ response = self.client.post(self.path())
+ assert response.status_code == 400
+
+ @ddt.data(
+ {'a': 1},
+ {'user_id': 'xyz', 'action': 'allow'},
+ {'user_id': 'staff', 'action': 123},
+ )
+ def test_missing_or_invalid_parameters(self, body):
+ """
+ Test the response when the POST request has missing required parameters or
+ invalid values for the required parameters.
+ """
+ self._login_as_staff()
+ response = self.client.post(self.path(), body)
+ assert response.status_code == 400
+
+ response = self.client.post(self.path(), body, format='json')
+ assert response.status_code == 400
+
+ @ddt.data(
+ {'action': 'allow', 'user_in_role': False},
+ {'action': 'allow', 'user_in_role': True},
+ {'action': 'revoke', 'user_in_role': False},
+ {'action': 'revoke', 'user_in_role': True}
+ )
+ @ddt.unpack
+ def test_post_update_user_role(self, action, user_in_role):
+ """Test the response when updating the user's role"""
+ users = self._create_and_enroll_users(count=1)
+ user = users[0]
+ role = 'Moderator'
+ if user_in_role:
+ self._add_users_to_role(users, role)
+
+ response = self.post(role, user.username, action)
+ assert response.status_code == 200
+ content = json.loads(response.content.decode('utf-8'))
+ assertion = self.assertTrue if action == 'allow' else self.assertFalse
+ assertion(any(user.username in x['username'] for x in content['results']))
diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py
index 342afb0ada..37512c3573 100644
--- a/lms/djangoapps/discussion/rest_api/tests/utils.py
+++ b/lms/djangoapps/discussion/rest_api/tests/utils.py
@@ -635,7 +635,8 @@ class ForumMockUtilsMixin(MockForumApiMixin):
self.set_mock_return_value('get_thread', thread)
def register_get_comments_response(self, comments, page, num_pages):
- self.set_mock_return_value('get_parent_comment', {
+ """Register a mock response for get_user_comments API call."""
+ self.set_mock_return_value('get_user_comments', {
"collection": comments,
"page": page,
"num_pages": num_pages,
@@ -675,13 +676,21 @@ class ForumMockUtilsMixin(MockForumApiMixin):
}
self.set_mock_side_effect('get_user', make_user_callbacks(self.users_map))
- def register_get_user_retire_response(self, user, body=""):
+ def register_get_user_retire_response(self, user, status=200, body=""):
self.set_mock_return_value('retire_user', body)
def register_get_username_replacement_response(self, user, status=200, body=""):
self.set_mock_return_value('update_username', body)
def register_subscribed_threads_response(self, user, threads, page, num_pages):
+ """Register a mock response for get_user_threads and get_user_subscriptions API calls."""
+ self.set_mock_return_value('get_user_threads', {
+ "collection": threads,
+ "page": page,
+ "num_pages": num_pages,
+ "thread_count": len(threads),
+ })
+ # Also mock get_user_subscriptions for the Forum v2 API
self.set_mock_return_value('get_user_subscriptions', {
"collection": threads,
"page": page,
diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py
index e1b451b3bc..5f4953d1e6 100644
--- a/lms/djangoapps/discussion/tests/test_views.py
+++ b/lms/djangoapps/discussion/tests/test_views.py
@@ -7,19 +7,15 @@ from datetime import datetime
from unittest import mock
from unittest.mock import ANY, Mock, call, patch
-import ddt
import pytest
-from django.conf import settings
from django.test.client import Client, RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import translation
from edx_django_utils.cache import RequestCache
-from edx_toggles.toggles.testutils import override_waffle_flag
from xmodule.modulestore.tests.django_utils import (
TEST_DATA_SPLIT_MODULESTORE,
ModuleStoreTestCase,
- SharedModuleStoreTestCase
)
from xmodule.modulestore.tests.factories import (
CourseFactory,
@@ -28,19 +24,17 @@ from xmodule.modulestore.tests.factories import (
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
-from common.djangoapps.student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory
+from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from common.djangoapps.util.testing import UrlResetMixin
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
from lms.djangoapps.discussion import views
from lms.djangoapps.discussion.django_comment_client.constants import TYPE_ENTRY, TYPE_SUBCATEGORY
from lms.djangoapps.discussion.django_comment_client.permissions import get_team
-from lms.djangoapps.discussion.django_comment_client.tests.unicode import UnicodeTestMixin
from lms.djangoapps.discussion.django_comment_client.tests.utils import (
ForumsEnableMixin,
config_course_discussions,
topic_name_to_id
)
-from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
from lms.djangoapps.discussion.views import _get_discussion_default_topic_id, course_discussions_settings_handler
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase
@@ -91,12 +85,6 @@ class ViewsExceptionTestCase(UrlResetMixin, ModuleStoreTestCase): # lint-amnest
config = ForumsConfig.current()
config.enabled = True
config.save()
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
@@ -336,12 +324,6 @@ class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, Mo
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
- patcher = mock.patch(
- 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
- return_value=False
- )
- patcher.start()
- self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
)
@@ -409,36 +391,6 @@ class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, Mo
self.assert_all_calls_have_header(mock_request, "X-Edx-Api-Key", "test_api_key")
-class UserProfileUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
-
- @classmethod
- def setUpClass(cls):
- # pylint: disable=super-method-not-called
- with super().setUpClassAndTestData():
- cls.course = CourseFactory.create()
-
- @classmethod
- def setUpTestData(cls):
- super().setUpTestData()
-
- cls.student = UserFactory.create()
- CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
-
- @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
- def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring
- mock_request.side_effect = make_mock_request_impl(course=self.course, text=text)
- request = RequestFactory().get("dummy_url")
- request.user = self.student
- # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True
- request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
-
- response = views.user_profile(request, str(self.course.id), str(self.student.id))
- assert response.status_code == 200
- response_data = json.loads(response.content.decode('utf-8'))
- assert response_data['discussion_data'][0]['title'] == text
- assert response_data['discussion_data'][0]['body'] == text
-
-
class EnrollmentTestCase(ForumsEnableMixin, ModuleStoreTestCase):
"""
Tests for the behavior of views depending on if the student is enrolled
@@ -746,76 +698,3 @@ class DefaultTopicIdGetterTestCase(ModuleStoreTestCase):
expected_id = 'another_discussion_id'
result = _get_discussion_default_topic_id(course)
assert expected_id == result
-
-
-@ddt.ddt
-@patch(
- 'openedx.core.djangoapps.django_comment_common.comment_client.utils.perform_request',
- Mock(
- return_value={
- "id": "test_thread",
- "title": "Title",
- "body": "",
- "default_sort_key": "date",
- "upvoted_ids": [],
- "downvoted_ids": [],
- "subscribed_thread_ids": [],
- }
- )
-)
-class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase):
- """
- Tests that the MFE upgrade banner and MFE is shown in the correct situation with the correct UI
- """
-
- def setUp(self):
- super().setUp()
- self.course = CourseFactory.create()
- self.user = UserFactory.create()
- self.staff_user = AdminFactory.create()
- CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
-
- @override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
- def test_redirect_from_legacy_base_url_to_new_experience(self):
- """
- Verify that the legacy url is redirected to MFE homepage when
- ENABLE_DISCUSSIONS_MFE flag is enabled.
- """
-
- with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
- self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
- url = reverse("forum_form_discussion", args=[self.course.id])
- response = self.client.get(url)
- assert response.status_code == 302
- expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}"
- assert response.url == expected_url
-
- @override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
- def test_redirect_from_legacy_profile_url_to_new_experience(self):
- """
- Verify that the requested user profile is redirected to MFE learners tab when
- ENABLE_DISCUSSIONS_MFE flag is enabled
- """
-
- with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
- self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
- url = reverse("user_profile", args=[self.course.id, self.user.id])
- response = self.client.get(url)
- assert response.status_code == 302
- expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/learners"
- assert response.url == expected_url
-
- @override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
- def test_redirect_from_legacy_single_thread_to_new_experience(self):
- """
- Verify that a legacy single url is redirected to corresponding MFE thread url when the ENABLE_DISCUSSIONS_MFE
- flag is enabled
- """
-
- with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
- self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
- url = reverse("single_thread", args=[self.course.id, "test_discussion", "test_thread"])
- response = self.client.get(url)
- assert response.status_code == 302
- expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/posts/test_thread"
- assert response.url == expected_url
diff --git a/lms/djangoapps/discussion/tests/test_views_v2.py b/lms/djangoapps/discussion/tests/test_views_v2.py
index f7d7e6f9f1..1e4f36b8f5 100644
--- a/lms/djangoapps/discussion/tests/test_views_v2.py
+++ b/lms/djangoapps/discussion/tests/test_views_v2.py
@@ -2022,3 +2022,115 @@ class FollowedThreadsUnicodeTestCase(
response_data = json.loads(response.content.decode('utf-8'))
assert response_data['discussion_data'][0]['title'] == text
assert response_data['discussion_data'][0]['body'] == text
+
+
+class UserProfileUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin, ForumViewsUtilsMixin): # lint-amnesty, pylint: disable=missing-class-docstring
+
+ @classmethod
+ def setUpClass(cls):
+ # pylint: disable=super-method-not-called
+ with super().setUpClassAndTestData():
+ cls.course = CourseFactory.create()
+ super().setUpClassAndForumMock()
+
+ @classmethod
+ def tearDownClass(cls):
+ super().tearDownClass()
+ super().disposeForumMocks()
+
+ @classmethod
+ def setUpTestData(cls):
+ super().setUpTestData()
+
+ cls.student = UserFactory.create()
+ CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
+
+ def _test_unicode_data(self, text): # lint-amnesty, pylint: disable=missing-function-docstring
+ self._configure_mock_responses(course=self.course, text=text)
+ request = RequestFactory().get("dummy_url")
+ request.user = self.student
+ # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True
+ request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
+
+ response = views.user_profile(request, str(self.course.id), str(self.student.id))
+ assert response.status_code == 200
+ response_data = json.loads(response.content.decode('utf-8'))
+ assert response_data['discussion_data'][0]['title'] == text
+ assert response_data['discussion_data'][0]['body'] == text
+
+
+class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase, ModuleStoreTestCase, MockForumApiMixin): # lint-amnesty, pylint: disable=missing-class-docstring
+ """
+ Tests that the MFE upgrade banner and MFE is shown in the correct situation with the correct UI
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.course = CourseFactory.create()
+ self.user = UserFactory.create()
+ self.staff_user = AdminFactory.create()
+ CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
+ self.set_mock_return_value("get_user", {
+ "id": "test_thread",
+ "title": "Title",
+ "body": "",
+ "default_sort_key": "date",
+ "upvoted_ids": [],
+ "downvoted_ids": [],
+ "subscribed_thread_ids": [],
+ })
+ self.set_mock_return_value("get_user_active_threads", {})
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ super().setUpClassAndForumMock()
+
+ @classmethod
+ def tearDownClass(cls):
+ super().tearDownClass()
+ super().disposeForumMocks()
+
+ @override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
+ def test_redirect_from_legacy_base_url_to_new_experience(self):
+ """
+ Verify that the legacy url is redirected to MFE homepage when
+ ENABLE_DISCUSSIONS_MFE flag is enabled.
+ """
+
+ with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
+ url = reverse("forum_form_discussion", args=[self.course.id])
+ response = self.client.get(url)
+ assert response.status_code == 302
+ expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}"
+ assert response.url == expected_url
+
+ @override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
+ def test_redirect_from_legacy_profile_url_to_new_experience(self):
+ """
+ Verify that the requested user profile is redirected to MFE learners tab when
+ ENABLE_DISCUSSIONS_MFE flag is enabled
+ """
+ with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
+ url = reverse("user_profile", args=[self.course.id, self.user.id])
+ response = self.client.get(url)
+ assert response.status_code == 302
+ expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/learners"
+ assert response.url == expected_url
+
+ @override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
+ def test_redirect_from_legacy_single_thread_to_new_experience(self):
+ """
+ Verify that a legacy single url is redirected to corresponding MFE thread url when the ENABLE_DISCUSSIONS_MFE
+ flag is enabled
+ """
+
+ with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
+ url = reverse("single_thread", args=[self.course.id, "test_discussion", "test_thread"])
+ response = self.client.get(url)
+ assert response.status_code == 302
+ expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/posts/test_thread"
+ assert response.url == expected_url
diff --git a/lms/djangoapps/experiments/tests/test_views.py b/lms/djangoapps/experiments/tests/test_views.py
index 1378fbd9f0..398e199c71 100644
--- a/lms/djangoapps/experiments/tests/test_views.py
+++ b/lms/djangoapps/experiments/tests/test_views.py
@@ -21,6 +21,7 @@ from lms.djangoapps.experiments.models import ExperimentData # lint-amnesty, py
from lms.djangoapps.experiments.serializers import ExperimentDataSerializer
from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
+from common.test.utils import assert_dict_contains_subset
CROSS_DOMAIN_REFERER = 'https://ecommerce.edx.org'
@@ -42,7 +43,7 @@ class ExperimentDataViewSetTests(APITestCase, ModuleStoreTestCase): # lint-amne
ExperimentData.objects.get(user=user)
data['user'] = user.username
- self.assertDictContainsSubset(data, response.data)
+ assert_dict_contains_subset(self, data, response.data)
def test_list_permissions(self):
""" Users should only be able to list their own data. """
diff --git a/lms/djangoapps/grades/tests/test_events.py b/lms/djangoapps/grades/tests/test_events.py
index eac8cc9a4a..4b3063265a 100644
--- a/lms/djangoapps/grades/tests/test_events.py
+++ b/lms/djangoapps/grades/tests/test_events.py
@@ -29,6 +29,7 @@ from lms.djangoapps.grades.models import PersistentCourseGrade
from lms.djangoapps.grades.tests.utils import mock_passing_grade
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
+from common.test.utils import assert_dict_contains_subset
class PersistentGradeEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
@@ -90,7 +91,8 @@ class PersistentGradeEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixi
PERSISTENT_GRADE_SUMMARY_CHANGED.connect(event_receiver)
grade = PersistentCourseGrade.update_or_create(**self.params)
self.assertTrue(self.receiver_called)
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
"signal": PERSISTENT_GRADE_SUMMARY_CHANGED,
"sender": None,
@@ -151,7 +153,8 @@ class CoursePassingStatusEventsTest(SharedModuleStoreTestCase, OpenEdxEventsTest
grade_factory.update(self.user, self.course)
self.assertTrue(self.receiver_called)
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
"signal": COURSE_PASSING_STATUS_UPDATED,
"sender": None,
@@ -224,7 +227,8 @@ class CCXCoursePassingStatusEventsTest(
grade_factory.update(self.user, self.store.get_course(self.ccx_locator))
self.assertTrue(self.receiver_called)
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
"signal": CCX_COURSE_PASSING_STATUS_UPDATED,
"sender": None,
diff --git a/lms/djangoapps/instructor_task/tests/test_integration.py b/lms/djangoapps/instructor_task/tests/test_integration.py
index 267f8021cd..e2a2c75497 100644
--- a/lms/djangoapps/instructor_task/tests/test_integration.py
+++ b/lms/djangoapps/instructor_task/tests/test_integration.py
@@ -44,6 +44,7 @@ from openedx.core.djangoapps.util.testing import TestConditionalContent
from openedx.core.lib.url_utils import quote_slashes
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import BlockFactory # lint-amnesty, pylint: disable=wrong-import-order
+from common.test.utils import assert_dict_contains_subset
log = logging.getLogger(__name__)
@@ -585,7 +586,7 @@ class TestGradeReportConditionalContent(TestReportMixin, TestConditionalContent,
Arguments:
task_result (dict): Return value of `CourseGradeReport.generate`.
"""
- self.assertDictContainsSubset({'attempted': 2, 'succeeded': 2, 'failed': 0}, task_result)
+ assert_dict_contains_subset(self, {'attempted': 2, 'succeeded': 2, 'failed': 0}, task_result)
def verify_grades_in_csv(self, students_grades, ignore_other_columns=False):
"""
diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
index 0a8c5e788b..4144dd9568 100644
--- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
+++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
@@ -77,6 +77,7 @@ from xmodule.tests.helpers import override_descriptor_system # pylint: disable=
from ..models import ReportStore
from ..tasks_helper.utils import UPDATE_STATUS_FAILED, UPDATE_STATUS_SUCCEEDED
+from common.test.utils import assert_dict_contains_subset
_TEAMS_CONFIG = TeamsConfig({
'max_size': 2,
@@ -97,7 +98,7 @@ class InstructorGradeReportTestCase(TestReportMixin, InstructorTaskCourseTestCas
with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'):
with patch(USE_ON_DISK_GRADE_REPORT, return_value=use_tempfile):
result = CourseGradeReport.generate(None, None, course_id, {}, 'graded')
- self.assertDictContainsSubset({'attempted': num_rows, 'succeeded': num_rows, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'attempted': num_rows, 'succeeded': num_rows, 'failed': 0}, result)
report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD')
report_csv_filename = report_store.links_for(course_id)[0][0]
report_path = report_store.path_to(course_id, report_csv_filename)
@@ -135,7 +136,7 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase):
with patch(USE_ON_DISK_GRADE_REPORT, return_value=use_tempfile):
result = CourseGradeReport.generate(None, None, self.course.id, {}, 'graded')
num_students = len(emails)
- self.assertDictContainsSubset({'attempted': num_students, 'succeeded': num_students, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'attempted': num_students, 'succeeded': num_students, 'failed': 0}, result)
@ddt.data(True, False)
@patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task')
@@ -150,7 +151,7 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase):
]
with patch(USE_ON_DISK_GRADE_REPORT, return_value=use_tempfile):
result = CourseGradeReport.generate(None, None, self.course.id, {}, 'graded')
- self.assertDictContainsSubset({'attempted': 1, 'succeeded': 0, 'failed': 1}, result)
+ assert_dict_contains_subset(self, {'attempted': 1, 'succeeded': 0, 'failed': 1}, result)
report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD')
assert any(('grade_report_err' in item[0]) for item in report_store.links_for(self.course.id))
@@ -334,7 +335,7 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase):
)
]
result = CourseGradeReport.generate(None, None, self.course.id, {}, 'graded')
- self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'attempted': 1, 'succeeded': 1, 'failed': 0}, result)
def test_certificate_eligibility(self):
"""
@@ -430,8 +431,10 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase):
self._verify_cell_data_for_user('inactive-student', self.course.id, 'Enrollment Status', NOT_ENROLLED_IN_COURSE)
expected_students = 2
- self.assertDictContainsSubset(
- {'attempted': expected_students, 'succeeded': expected_students, 'failed': 0}, result
+ assert_dict_contains_subset(
+ self,
+ {'attempted': expected_students, 'succeeded': expected_students, 'failed': 0},
+ result,
)
@@ -539,12 +542,16 @@ class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase):
usage_key_str_list=[str(problem.location)],
)
assert len(student_data) == 1
- self.assertDictContainsSubset({
- 'username': 'student',
- 'location': 'test_course > Section > Subsection > Problem1',
- 'block_key': 'block-v1:edx+1.23x+test_course+type@problem+block@Problem1',
- 'title': 'Problem1',
- }, student_data[0])
+ assert_dict_contains_subset(
+ self,
+ {
+ 'username': 'student',
+ 'location': 'test_course > Section > Subsection > Problem1',
+ 'block_key': 'block-v1:edx+1.23x+test_course+type@problem+block@Problem1',
+ 'title': 'Problem1',
+ },
+ student_data[0],
+ )
assert 'state' in student_data[0]
assert student_data_keys_list == ['username', 'title', 'location', 'block_key', 'state']
mock_list_problem_responses.assert_called_with(self.course.id, ANY, ANY)
@@ -569,22 +576,30 @@ class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase):
usage_key_str_list=[str(self.course.location)],
)
assert len(student_data) == 2
- self.assertDictContainsSubset({
- 'username': 'student',
- 'location': 'test_course > Section > Subsection > Problem1',
- 'block_key': 'block-v1:edx+1.23x+test_course+type@problem+block@Problem1',
- 'title': 'Problem1',
- 'some': 'state1',
- 'more': 'state1!',
- }, student_data[0])
- self.assertDictContainsSubset({
- 'username': 'student',
- 'location': 'test_course > Section > Subsection > Problem1',
- 'block_key': 'block-v1:edx+1.23x+test_course+type@problem+block@Problem1',
- 'title': 'Problem1',
- 'some': 'state2',
- 'more': 'state2!',
- }, student_data[1])
+ assert_dict_contains_subset(
+ self,
+ {
+ 'username': 'student',
+ 'location': 'test_course > Section > Subsection > Problem1',
+ 'block_key': 'block-v1:edx+1.23x+test_course+type@problem+block@Problem1',
+ 'title': 'Problem1',
+ 'some': 'state1',
+ 'more': 'state1!',
+ },
+ student_data[0],
+ )
+ assert_dict_contains_subset(
+ self,
+ {
+ 'username': 'student',
+ 'location': 'test_course > Section > Subsection > Problem1',
+ 'block_key': 'block-v1:edx+1.23x+test_course+type@problem+block@Problem1',
+ 'title': 'Problem1',
+ 'some': 'state2',
+ 'more': 'state2!',
+ },
+ student_data[1],
+ )
assert student_data[0]['state'] == student_data[1]['state']
assert student_data_keys_list == ['username', 'title', 'location', 'more', 'some', 'block_key', 'state']
@@ -610,22 +625,30 @@ class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase):
usage_key_str_list=[str(self.course.location)],
)
assert len(student_data) == 2
- self.assertDictContainsSubset({
- 'username': 'student',
- 'location': 'test_course > Section > Subsection > Problem1',
- 'block_key': 'block-v1:edx+1.23x+test_course+type@problem+block@Problem1',
- 'title': 'Problem1',
- 'some': 'state1',
- 'more': 'state1!',
- }, student_data[0])
- self.assertDictContainsSubset({
- 'username': 'student',
- 'location': 'test_course > Section > Subsection > Problem1',
- 'block_key': 'block-v1:edx+1.23x+test_course+type@problem+block@Problem1',
- 'title': 'Problem1',
- 'some': 'state2',
- 'more': 'state2!',
- }, student_data[1])
+ assert_dict_contains_subset(
+ self,
+ {
+ 'username': 'student',
+ 'location': 'test_course > Section > Subsection > Problem1',
+ 'block_key': 'block-v1:edx+1.23x+test_course+type@problem+block@Problem1',
+ 'title': 'Problem1',
+ 'some': 'state1',
+ 'more': 'state1!',
+ },
+ student_data[0],
+ )
+ assert_dict_contains_subset(
+ self,
+ {
+ 'username': 'student',
+ 'location': 'test_course > Section > Subsection > Problem1',
+ 'block_key': 'block-v1:edx+1.23x+test_course+type@problem+block@Problem1',
+ 'title': 'Problem1',
+ 'some': 'state2',
+ 'more': 'state2!',
+ },
+ student_data[1],
+ )
assert student_data[0]['state'] == student_data[1]['state']
assert student_data_keys_list == ['username', 'title', 'location', 'some', 'more', 'block_key', 'state']
@@ -642,16 +665,20 @@ class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase):
usage_key_str_list=[str(self.course.location)],
)
assert len(student_data) == 1
- self.assertDictContainsSubset({
- 'username': 'student',
- 'location': 'test_course > Section > Subsection > Problem1',
- 'block_key': 'block-v1:edx+1.23x+test_course+type@problem+block@Problem1',
- 'title': 'Problem1',
- 'Answer ID': 'Problem1_2_1',
- 'Answer': 'Option 1',
- 'Correct Answer': 'Option 1',
- 'Question': 'The correct answer is Option 1',
- }, student_data[0])
+ assert_dict_contains_subset(
+ self,
+ {
+ 'username': 'student',
+ 'location': 'test_course > Section > Subsection > Problem1',
+ 'block_key': 'block-v1:edx+1.23x+test_course+type@problem+block@Problem1',
+ 'title': 'Problem1',
+ 'Answer ID': 'Problem1_2_1',
+ 'Answer': 'Option 1',
+ 'Correct Answer': 'Option 1',
+ 'Question': 'The correct answer is Option 1',
+ },
+ student_data[0],
+ )
assert 'state' in student_data[0]
assert student_data_keys_list == ['username', 'title', 'location', 'Answer', 'Answer ID', 'Correct Answer',
'Question', 'block_key', 'state']
@@ -671,16 +698,20 @@ class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase):
)
assert len(student_data) == 2
for idx in range(1, 3):
- self.assertDictContainsSubset({
- 'username': 'student',
- 'location': f'test_course > Section > Subsection > Problem{idx}',
- 'block_key': f'block-v1:edx+1.23x+test_course+type@problem+block@Problem{idx}',
- 'title': f'Problem{idx}',
- 'Answer ID': f'Problem{idx}_2_1',
- 'Answer': 'Option 1',
- 'Correct Answer': 'Option 1',
- 'Question': 'The correct answer is Option 1',
- }, student_data[idx - 1])
+ assert_dict_contains_subset(
+ self,
+ {
+ 'username': 'student',
+ 'location': f'test_course > Section > Subsection > Problem{idx}',
+ 'block_key': f'block-v1:edx+1.23x+test_course+type@problem+block@Problem{idx}',
+ 'title': f'Problem{idx}',
+ 'Answer ID': f'Problem{idx}_2_1',
+ 'Answer': 'Option 1',
+ 'Correct Answer': 'Option 1',
+ 'Question': 'The correct answer is Option 1',
+ },
+ student_data[idx - 1],
+ )
assert 'state' in student_data[(idx - 1)]
@ddt.data(
@@ -819,7 +850,11 @@ class TestProblemGradeReport(TestReportMixin, InstructorTaskModuleTestCase):
"""
with patch(USE_ON_DISK_GRADE_REPORT, return_value=use_tempfile):
result = ProblemGradeReport.generate(None, None, self.course.id, {}, 'graded')
- self.assertDictContainsSubset({'action_name': 'graded', 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
+ assert_dict_contains_subset(
+ self,
+ {'action_name': 'graded', 'attempted': 2, 'succeeded': 2, 'failed': 0},
+ result
+ )
self.verify_rows_in_csv([
dict(list(zip(
self.csv_header_row,
@@ -845,7 +880,11 @@ class TestProblemGradeReport(TestReportMixin, InstructorTaskModuleTestCase):
self.submit_student_answer(self.student_1.username, 'Problem1', ['Option 1'])
with patch(USE_ON_DISK_GRADE_REPORT, return_value=use_tempfile):
result = ProblemGradeReport.generate(None, None, self.course.id, {}, 'graded')
- self.assertDictContainsSubset({'action_name': 'graded', 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
+ assert_dict_contains_subset(
+ self,
+ {'action_name': 'graded', 'attempted': 2, 'succeeded': 2, 'failed': 0},
+ result
+ )
problem_name = 'Homework 1: Subsection - Problem1'
header_row = self.csv_header_row + [problem_name + ' (Earned)', problem_name + ' (Possible)']
self.verify_rows_in_csv([
@@ -891,8 +930,10 @@ class TestProblemGradeReport(TestReportMixin, InstructorTaskModuleTestCase):
self.submit_student_answer(student_verified.username, 'Problem1', ['Option 1'])
with patch(USE_ON_DISK_GRADE_REPORT, return_value=use_tempfile):
result = ProblemGradeReport.generate(None, None, self.course.id, {}, 'graded')
- self.assertDictContainsSubset(
- {'action_name': 'graded', 'attempted': 1, 'succeeded': 1, 'failed': 0}, result
+ assert_dict_contains_subset(
+ self,
+ {'action_name': 'graded', 'attempted': 1, 'succeeded': 1, 'failed': 0},
+ result,
)
@patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task')
@@ -913,7 +954,11 @@ class TestProblemGradeReport(TestReportMixin, InstructorTaskModuleTestCase):
self.submit_student_answer(self.student_1.username, 'Problem1', ['Option 1'])
with patch(USE_ON_DISK_GRADE_REPORT, return_value=use_tempfile):
result = ProblemGradeReport.generate(None, None, self.course.id, {}, 'graded')
- self.assertDictContainsSubset({'action_name': 'graded', 'attempted': 3, 'succeeded': 3, 'failed': 0}, result)
+ assert_dict_contains_subset(
+ self,
+ {'action_name': 'graded', 'attempted': 3, 'succeeded': 3, 'failed': 0},
+ result
+ )
problem_name = 'Homework 1: Subsection - Problem1'
header_row = self.csv_header_row + [problem_name + ' (Earned)', problem_name + ' (Possible)']
self.verify_rows_in_csv([
@@ -987,8 +1032,10 @@ class TestProblemReportSplitTestContent(TestReportMixin, TestConditionalContent,
with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'):
with patch(USE_ON_DISK_GRADE_REPORT, return_value=use_tempfile):
result = ProblemGradeReport.generate(None, None, self.course.id, {}, 'graded')
- self.assertDictContainsSubset(
- {'action_name': 'graded', 'attempted': 2, 'succeeded': 2, 'failed': 0}, result
+ assert_dict_contains_subset(
+ self,
+ {'action_name': 'graded', 'attempted': 2, 'succeeded': 2, 'failed': 0},
+ result,
)
problem_names = ['Homework 1: Subsection - problem_a_url', 'Homework 1: Subsection - problem_b_url']
@@ -1143,8 +1190,10 @@ class TestProblemReportCohortedContent(TestReportMixin, ContentGroupTestCase, In
with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'):
with patch(USE_ON_DISK_GRADE_REPORT, return_value=use_tempfile):
result = ProblemGradeReport.generate(None, None, self.course.id, {}, 'graded')
- self.assertDictContainsSubset(
- {'action_name': 'graded', 'attempted': 5, 'succeeded': 5, 'failed': 0}, result
+ assert_dict_contains_subset(
+ self,
+ {'action_name': 'graded', 'attempted': 5, 'succeeded': 5, 'failed': 0},
+ result,
)
problem_names = ['Homework 1: Subsection - Problem0', 'Homework 1: Subsection - Problem1']
header_row = ['Student ID', 'Email', 'Username', 'Enrollment Status', 'Grade']
@@ -1228,7 +1277,7 @@ class TestCourseSurveyReport(TestReportMixin, InstructorTaskCourseTestCase):
None, None, self.course.id,
task_input, 'generating course survey report'
)
- self.assertDictContainsSubset({'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
def test_generate_course_survey_report(self):
"""
@@ -1262,7 +1311,7 @@ class TestCourseSurveyReport(TestReportMixin, InstructorTaskCourseTestCase):
])
expected_data = [header_row, student1_row, student2_row]
- self.assertDictContainsSubset({'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
self._verify_csv_file_report(report_store, expected_data)
def _verify_csv_file_report(self, report_store, expected_data):
@@ -1297,7 +1346,7 @@ class TestStudentReport(TestReportMixin, InstructorTaskCourseTestCase):
links = report_store.links_for(self.course.id)
assert len(links) == 1
- self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'attempted': 1, 'succeeded': 1, 'failed': 0}, result)
def test_custom_directory(self):
self.create_student('student', 'student@example.com')
@@ -1352,7 +1401,7 @@ class TestStudentReport(TestReportMixin, InstructorTaskCourseTestCase):
result = upload_students_csv(None, None, self.course.id, task_input, 'calculated')
# This assertion simply confirms that the generation completed with no errors
num_students = len(students)
- self.assertDictContainsSubset({'attempted': num_students, 'succeeded': num_students, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'attempted': num_students, 'succeeded': num_students, 'failed': 0}, result)
class TestTeamStudentReport(TestReportMixin, InstructorTaskCourseTestCase):
@@ -1382,7 +1431,7 @@ class TestTeamStudentReport(TestReportMixin, InstructorTaskCourseTestCase):
with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task') as mock_current_task:
mock_current_task.return_value = current_task
result = upload_students_csv(None, None, self.course.id, task_input, 'calculated')
- self.assertDictContainsSubset({'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD')
report_csv_filename = report_store.links_for(self.course.id)[0][0]
report_path = report_store.path_to(self.course.id, report_csv_filename)
@@ -1447,7 +1496,7 @@ class TestListMayEnroll(TestReportMixin, InstructorTaskCourseTestCase):
links = report_store.links_for(self.course.id)
assert len(links) == 1
- self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'attempted': 1, 'succeeded': 1, 'failed': 0}, result)
def test_unicode_email_addresses(self):
"""
@@ -1463,7 +1512,11 @@ class TestListMayEnroll(TestReportMixin, InstructorTaskCourseTestCase):
result = upload_may_enroll_csv(None, None, self.course.id, task_input, 'calculated')
# This assertion simply confirms that the generation completed with no errors
num_enrollments = len(enrollments)
- self.assertDictContainsSubset({'attempted': num_enrollments, 'succeeded': num_enrollments, 'failed': 0}, result)
+ assert_dict_contains_subset(
+ self,
+ {'attempted': num_enrollments, 'succeeded': num_enrollments, 'failed': 0},
+ result
+ )
class MockDefaultStorage:
@@ -1510,7 +1563,7 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
'student_1\xec,,Cohort 1\n'
'student_2,,Cohort 2'
)
- self.assertDictContainsSubset({'total': 2, 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'total': 2, 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
self.verify_rows_in_csv(
[
dict(list(zip(self.csv_header_row, ['Cohort 1', 'True', '1', '', '', '']))),
@@ -1525,7 +1578,7 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
',student_1@example.com,Cohort 1\n'
',student_2@example.com,Cohort 2'
)
- self.assertDictContainsSubset({'total': 2, 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'total': 2, 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
self.verify_rows_in_csv(
[
dict(list(zip(self.csv_header_row, ['Cohort 1', 'True', '1', '', '', '']))),
@@ -1540,7 +1593,7 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
'student_1\xec,student_1@example.com,Cohort 1\n'
'student_2,student_2@example.com,Cohort 2'
)
- self.assertDictContainsSubset({'total': 2, 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'total': 2, 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
self.verify_rows_in_csv(
[
dict(list(zip(self.csv_header_row, ['Cohort 1', 'True', '1', '', '', '']))),
@@ -1561,7 +1614,7 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
'student_1\xec,student_1@example.com,Cohort 1\n' # valid username and email
'Invalid,student_2@example.com,Cohort 2' # invalid username, valid email
)
- self.assertDictContainsSubset({'total': 2, 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'total': 2, 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
self.verify_rows_in_csv(
[
dict(list(zip(self.csv_header_row, ['Cohort 1', 'True', '1', '', '', '']))),
@@ -1575,7 +1628,7 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
'username,email,cohort\n'
'Invalid,,Cohort 1\n'
)
- self.assertDictContainsSubset({'total': 1, 'attempted': 1, 'succeeded': 0, 'failed': 1}, result)
+ assert_dict_contains_subset(self, {'total': 1, 'attempted': 1, 'succeeded': 0, 'failed': 1}, result)
self.verify_rows_in_csv(
[
dict(list(zip(self.csv_header_row, ['Cohort 1', 'True', '0', 'Invalid', '', '']))),
@@ -1589,7 +1642,7 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
',student_1@example.com,Does Not Exist\n'
'student_2,,Cohort 2'
)
- self.assertDictContainsSubset({'total': 2, 'attempted': 2, 'succeeded': 1, 'failed': 1}, result)
+ assert_dict_contains_subset(self, {'total': 2, 'attempted': 2, 'succeeded': 1, 'failed': 1}, result)
self.verify_rows_in_csv(
[
dict(list(zip(self.csv_header_row, ['Does Not Exist', 'False', '0', '', '', '']))),
@@ -1603,8 +1656,11 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
'username,email,cohort\n'
',example_email@example.com,Cohort 1'
)
- self.assertDictContainsSubset({'total': 1, 'attempted': 1, 'succeeded': 0, 'failed': 0},
- result)
+ assert_dict_contains_subset(
+ self,
+ {'total': 1, 'attempted': 1, 'succeeded': 0, 'failed': 0},
+ result,
+ )
self.verify_rows_in_csv(
[
dict(list(zip(self.csv_header_row, ['Cohort 1', 'True', '0', '', '', 'example_email@example.com']))),
@@ -1617,7 +1673,7 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
'username,email,cohort\n'
',student_1@,Cohort 1\n'
)
- self.assertDictContainsSubset({'total': 1, 'attempted': 1, 'succeeded': 0, 'failed': 1}, result)
+ assert_dict_contains_subset(self, {'total': 1, 'attempted': 1, 'succeeded': 0, 'failed': 1}, result)
self.verify_rows_in_csv(
[
dict(list(zip(self.csv_header_row, ['Cohort 1', 'True', '0', '', 'student_1@', '']))),
@@ -1642,7 +1698,7 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
'student_1\xec,\n'
'student_2'
)
- self.assertDictContainsSubset({'total': 2, 'attempted': 2, 'succeeded': 0, 'failed': 2}, result)
+ assert_dict_contains_subset(self, {'total': 2, 'attempted': 2, 'succeeded': 0, 'failed': 2}, result)
self.verify_rows_in_csv(
[
dict(list(zip(self.csv_header_row, ['', 'False', '0', '', '', '']))),
@@ -1654,7 +1710,7 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
result = self._cohort_students_and_upload(
'username,email,cohort'
)
- self.assertDictContainsSubset({'total': 0, 'attempted': 0, 'succeeded': 0, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'total': 0, 'attempted': 0, 'succeeded': 0, 'failed': 0}, result)
self.verify_rows_in_csv([])
def test_carriage_return(self):
@@ -1666,7 +1722,7 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
'student_1\xec,,Cohort 1\r'
'student_2,,Cohort 2'
)
- self.assertDictContainsSubset({'total': 2, 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'total': 2, 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
self.verify_rows_in_csv(
[
dict(list(zip(self.csv_header_row, ['Cohort 1', 'True', '1', '', '', '']))),
@@ -1684,7 +1740,7 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
'student_1\xec,,Cohort 1\r\n'
'student_2,,Cohort 2'
)
- self.assertDictContainsSubset({'total': 2, 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'total': 2, 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
self.verify_rows_in_csv(
[
dict(list(zip(self.csv_header_row, ['Cohort 1', 'True', '1', '', '', '']))),
@@ -1704,7 +1760,7 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
'student_1\xec,,Cohort 2\n'
'student_2,,Cohort 1'
)
- self.assertDictContainsSubset({'total': 2, 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'total': 2, 'attempted': 2, 'succeeded': 2, 'failed': 0}, result)
self.verify_rows_in_csv(
[
dict(list(zip(self.csv_header_row, ['Cohort 1', 'True', '1', '', '', '']))),
@@ -1724,7 +1780,7 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase):
'student_1\xec,,Cohort 1\n'
'student_2,,Cohort 2'
)
- self.assertDictContainsSubset({'total': 2, 'attempted': 2, 'skipped': 2, 'failed': 0}, result)
+ assert_dict_contains_subset(self, {'total': 2, 'attempted': 2, 'skipped': 2, 'failed': 0}, result)
self.verify_rows_in_csv(
[
dict(list(zip(self.csv_header_row, ['Cohort 1', 'True', '0', '', '', '']))),
@@ -1809,7 +1865,8 @@ class TestGradeReport(TestReportMixin, InstructorTaskModuleTestCase):
with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'):
result = CourseGradeReport.generate(None, None, self.course.id, {}, 'graded')
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{'action_name': 'graded', 'attempted': 1, 'succeeded': 1, 'failed': 0},
result,
)
@@ -1868,7 +1925,8 @@ class TestGradeReport(TestReportMixin, InstructorTaskModuleTestCase):
with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'):
result = CourseGradeReport.generate(None, None, self.course.id, {}, 'graded')
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{'action_name': 'graded', 'attempted': 1, 'succeeded': 1, 'failed': 0},
result,
)
@@ -1912,8 +1970,10 @@ class TestGradeReport(TestReportMixin, InstructorTaskModuleTestCase):
self.submit_student_answer(student_1.username, 'Problem4', ['Option 1'])
self.submit_student_answer(student_verified.username, 'Problem4', ['Option 1'])
result = CourseGradeReport.generate(None, None, self.course.id, {}, 'graded')
- self.assertDictContainsSubset(
- {'action_name': 'graded', 'attempted': 1, 'succeeded': 1, 'failed': 0}, result
+ assert_dict_contains_subset(
+ self,
+ {'action_name': 'graded', 'attempted': 1, 'succeeded': 1, 'failed': 0},
+ result,
)
@ddt.data(True, False)
@@ -2517,9 +2577,10 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
None, None, self.course.id, task_input, 'certificates generated'
)
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
expected_results,
- result
+ result,
)
def _create_students(self, number_of_students):
diff --git a/lms/djangoapps/program_enrollments/models.py b/lms/djangoapps/program_enrollments/models.py
index 04114a9dd5..2abec16d93 100644
--- a/lms/djangoapps/program_enrollments/models.py
+++ b/lms/djangoapps/program_enrollments/models.py
@@ -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
diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py
index 4abc4508ea..df713daf0b 100644
--- a/lms/djangoapps/support/tests/test_views.py
+++ b/lms/djangoapps/support/tests/test_views.py
@@ -52,7 +52,7 @@ from common.djangoapps.student.tests.factories import (
UserFactory,
)
from common.djangoapps.third_party_auth.tests.factories import SAMLProviderConfigFactory
-from common.test.utils import disable_signal
+from common.test.utils import disable_signal, assert_dict_contains_subset
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
from lms.djangoapps.support.models import CourseResetAudit
from lms.djangoapps.support.serializers import ProgramEnrollmentSerializer
@@ -343,14 +343,18 @@ class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase
assert response.status_code == 200
data = json.loads(response.content.decode('utf-8'))
assert len(data) == 1
- self.assertDictContainsSubset({
- 'mode': CourseMode.AUDIT,
- 'manual_enrollment': {},
- 'user': self.student.username,
- 'course_id': str(self.course.id),
- 'is_active': True,
- 'verified_upgrade_deadline': None,
- }, data[0])
+ assert_dict_contains_subset(
+ self,
+ {
+ 'mode': CourseMode.AUDIT,
+ 'manual_enrollment': {},
+ 'user': self.student.username,
+ 'course_id': str(self.course.id),
+ 'is_active': True,
+ 'verified_upgrade_deadline': None,
+ },
+ data[0],
+ )
assert {CourseMode.VERIFIED, CourseMode.AUDIT, CourseMode.HONOR, CourseMode.NO_ID_PROFESSIONAL_MODE,
CourseMode.PROFESSIONAL, CourseMode.CREDIT_MODE} == {mode['slug'] for mode in data[0]['course_modes']}
assert 'enterprise_course_enrollments' not in data[0]
@@ -471,10 +475,14 @@ class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase
)
response = self.client.get(self.url)
assert response.status_code == 200
- self.assertDictContainsSubset({
- 'enrolled_by': self.user.email,
- 'reason': 'Financial Assistance',
- }, json.loads(response.content.decode('utf-8'))[0]['manual_enrollment'])
+ assert_dict_contains_subset(
+ self,
+ {
+ 'enrolled_by': self.user.email,
+ 'reason': 'Financial Assistance',
+ },
+ json.loads(response.content.decode('utf-8'))[0]['manual_enrollment'],
+ )
@disable_signal(signals, 'post_save')
@ddt.data('username', 'email')
diff --git a/lms/envs/production.py b/lms/envs/production.py
index 0620d4f2c0..aeccaf0c0f 100644
--- a/lms/envs/production.py
+++ b/lms/envs/production.py
@@ -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:
diff --git a/lms/static/sass/_build-lms-v1.scss b/lms/static/sass/_build-lms-v1.scss
index 4d64e67685..90c5077c1f 100644
--- a/lms/static/sass/_build-lms-v1.scss
+++ b/lms/static/sass/_build-lms-v1.scss
@@ -67,7 +67,6 @@
// features
@import 'features/bookmarks-v1';
-@import "features/announcements";
@import 'features/_unsupported-browser-alert';
@import 'features/content-type-gating';
@import 'features/course-duration-limits';
diff --git a/lms/static/sass/features/_announcements.scss b/lms/static/sass/features/_announcements.scss
deleted file mode 100644
index 0c3c01fe60..0000000000
--- a/lms/static/sass/features/_announcements.scss
+++ /dev/null
@@ -1,28 +0,0 @@
-// lms - features - announcements
-// ====================
-.announcements-list {
- display: inline-block;
- width: 100%;
-
- .announcement {
- background-color: $course-profile-bg;
- align-content: center;
- text-align: center;
- padding: 22px 33px;
- margin-bottom: 15px;
- }
-
- .announcement-button {
- display: inline-block;
- padding: 3px 10px;
- font-size: 0.75rem;
- }
-
- .prev {
- float: left;
- }
-
- .next {
- float: right;
- }
-}
diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html
index d44cdefc63..6149418035 100644
--- a/lms/templates/dashboard.html
+++ b/lms/templates/dashboard.html
@@ -294,16 +294,6 @@ from common.djangoapps.student.models import CourseEnrollment
% endif
-
- <%block name="skip_links">
- % if settings.FEATURES.get('ENABLE_ANNOUNCEMENTS'):
- ${_("Skip to list of announcements")}
- % endif
- %block>
- % if settings.FEATURES.get('ENABLE_ANNOUNCEMENTS'):
- <%include file='dashboard/_dashboard_announcements.html' />
- % endif
-
diff --git a/lms/templates/header/user_dropdown.html b/lms/templates/header/user_dropdown.html
index b4b22e0e32..a489c17412 100644
--- a/lms/templates/header/user_dropdown.html
+++ b/lms/templates/header/user_dropdown.html
@@ -44,7 +44,7 @@ should_show_order_history = not enterprise_customer_portal
% endif
-
+
% if should_show_order_history:
diff --git a/openedx/core/djangoapps/content/course_overviews/models.py b/openedx/core/djangoapps/content/course_overviews/models.py
index 10a56f0868..c7264fd9d9 100644
--- a/openedx/core/djangoapps/content/course_overviews/models.py
+++ b/openedx/core/djangoapps/content/course_overviews/models.py
@@ -266,10 +266,20 @@ class CourseOverview(TimeStampedModel):
course_overview.entrance_exam_id = course.entrance_exam_id or ''
# Despite it being a float, the course object defaults to an int. So we will detect that case and update
# it to be a float like everything else.
- if isinstance(course.entrance_exam_minimum_score_pct, int):
- course_overview.entrance_exam_minimum_score_pct = course.entrance_exam_minimum_score_pct / 100
+ # Extra handling: entrance_exam_minimum_score_pct can be None (e.g. when exams are disabled in Studio),
+ # so we fall back to settings.ENTRANCE_EXAM_MIN_SCORE_PCT to prevent CourseOverview save failures.
+ if course.entrance_exam_minimum_score_pct is None:
+ entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT)
else:
- course_overview.entrance_exam_minimum_score_pct = course.entrance_exam_minimum_score_pct
+ entrance_exam_minimum_score_pct = course.entrance_exam_minimum_score_pct
+
+ if (
+ isinstance(entrance_exam_minimum_score_pct, int)
+ or (isinstance(entrance_exam_minimum_score_pct, float) and entrance_exam_minimum_score_pct.is_integer())
+ ):
+ entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100
+
+ course_overview.entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct
course_overview.force_on_flexible_peer_openassessments = course.force_on_flexible_peer_openassessments
diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py
index ff90c69725..658c55a0e4 100644
--- a/openedx/core/djangoapps/content_libraries/api/libraries.py
+++ b/openedx/core/djangoapps/content_libraries/api/libraries.py
@@ -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
diff --git a/openedx/core/djangoapps/content_libraries/rest_api/libraries.py b/openedx/core/djangoapps/content_libraries/rest_api/libraries.py
index 869b65a3ea..1acdf7bb11 100644
--- a/openedx/core/djangoapps/content_libraries/rest_api/libraries.py
+++ b/openedx/core/djangoapps/content_libraries/rest_api/libraries.py
@@ -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
# =============
diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py
index 9cdbe43901..3b4dba09a1 100644
--- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py
+++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py
@@ -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)
diff --git a/openedx/core/djangoapps/content_libraries/tasks.py b/openedx/core/djangoapps/content_libraries/tasks.py
index ebc8e27830..8c362dd526 100644
--- a/openedx/core/djangoapps/content_libraries/tasks.py
+++ b/openedx/core/djangoapps/content_libraries/tasks.py
@@ -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)})
diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py
index 1c1bf1b137..7002f41eca 100644
--- a/openedx/core/djangoapps/content_libraries/tests/base.py
+++ b/openedx/core/djangoapps/content_libraries/tests/base.py
@@ -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.
diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py
index 6756e4373a..d92a97530c 100644
--- a/openedx/core/djangoapps/content_libraries/tests/test_api.py
+++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py
@@ -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
diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py
index e2fec3aee1..c465fdd03e 100644
--- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py
+++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py
@@ -415,11 +415,11 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest):
# Add a 'html' XBlock to the library:
create_date = datetime(2024, 6, 6, 6, 6, 6, tzinfo=timezone.utc)
with freeze_time(create_date):
- block_data = self._add_block_to_library(lib_id, "html", "html1")
+ block_data = self._add_block_to_library(lib_id, "problem", "problem1")
self.assertDictContainsEntries(block_data, {
- "id": "lb:CL-TEST:testlib2:html:html1",
- "display_name": "Text",
- "block_type": "html",
+ "id": "lb:CL-TEST:testlib2:problem:problem1",
+ "display_name": "Blank Problem",
+ "block_type": "problem",
"has_unpublished_changes": True,
"last_published": None,
"published_by": None,
@@ -441,14 +441,14 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest):
block_data["has_unpublished_changes"] = False
block_data["last_published"] = publish_date.isoformat().replace('+00:00', 'Z')
block_data["published_by"] = "Bob"
- block_data["published_display_name"] = "Text"
+ block_data["published_display_name"] = "Blank Problem"
self.assertDictContainsEntries(self._get_library_block(block_id), block_data)
assert self._get_library_blocks(lib_id)['results'] == [block_data]
# Now update the block's OLX:
orig_olx = self._get_library_block_olx(block_id)
- assert ' 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
diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py
index d0e30a4200..f59a36e6f0 100644
--- a/openedx/core/djangoapps/content_libraries/urls.py
+++ b/openedx/core/djangoapps/content_libraries/urls.py
@@ -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)),
])),
diff --git a/openedx/core/djangoapps/course_groups/tests/test_events.py b/openedx/core/djangoapps/course_groups/tests/test_events.py
index 616a7bb3f1..f11e95088f 100644
--- a/openedx/core/djangoapps/course_groups/tests/test_events.py
+++ b/openedx/core/djangoapps/course_groups/tests/test_events.py
@@ -18,6 +18,7 @@ from openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
+from common.test.utils import assert_dict_contains_subset
@skip_unless_lms
@@ -90,7 +91,8 @@ class CohortEventTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
)
self.assertTrue(self.receiver_called)
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
"signal": COHORT_MEMBERSHIP_CHANGED,
"sender": None,
@@ -110,5 +112,5 @@ class CohortEventTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin):
name=cohort_membership.course_user_group.name,
),
},
- event_receiver.call_args.kwargs
+ event_receiver.call_args.kwargs,
)
diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py
index ee37835b48..1dcfc740c8 100644
--- a/openedx/core/djangoapps/courseware_api/views.py
+++ b/openedx/core/djangoapps/courseware_api/views.py
@@ -288,7 +288,7 @@ class CoursewareMeta:
get_certificate_url(course_id=self.course_key, uuid=user_certificate.verify_uuid)
)
return linkedin_config.add_to_profile_url(
- self.course_overview.display_name, user_certificate.mode, cert_url, certificate=user_certificate,
+ self.course_overview, user_certificate.mode, cert_url, certificate=user_certificate,
)
@property
diff --git a/openedx/core/djangoapps/discussions/config/waffle.py b/openedx/core/djangoapps/discussions/config/waffle.py
index eca6fc9708..1d4c67e9e1 100644
--- a/openedx/core/djangoapps/discussions/config/waffle.py
+++ b/openedx/core/djangoapps/discussions/config/waffle.py
@@ -2,8 +2,6 @@
This module contains various configuration settings via
waffle switches for the discussions app.
"""
-from django.conf import settings
-
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
WAFFLE_FLAG_NAMESPACE = "discussions"
@@ -45,31 +43,3 @@ ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND = CourseWaffleFlag(
ENABLE_NEW_STRUCTURE_DISCUSSIONS = CourseWaffleFlag(
f"{WAFFLE_FLAG_NAMESPACE}.enable_new_structure_discussions", __name__
)
-
-# .. toggle_name: discussions.enable_forum_v2
-# .. toggle_implementation: CourseWaffleFlag
-# .. toggle_default: False
-# .. toggle_description: Waffle flag to use the forum v2 instead of v1(cs_comment_service)
-# .. toggle_use_cases: temporary, open_edx
-# .. toggle_creation_date: 2024-9-26
-# .. toggle_target_removal_date: 2025-12-05
-ENABLE_FORUM_V2 = CourseWaffleFlag(f"{WAFFLE_FLAG_NAMESPACE}.enable_forum_v2", __name__)
-
-
-def is_forum_v2_enabled(course_key):
- """
- Returns whether forum V2 is enabled on the course. This is a 2-step check:
-
- 1. Check value of settings.DISABLE_FORUM_V2: if it exists and is true, this setting overrides any course flag.
- 2. Else, check the value of the corresponding course waffle flag.
- """
- if is_forum_v2_disabled_globally():
- return False
- return ENABLE_FORUM_V2.is_enabled(course_key)
-
-
-def is_forum_v2_disabled_globally() -> bool:
- """
- Return True if DISABLE_FORUM_V2 is defined and true-ish.
- """
- return getattr(settings, "DISABLE_FORUM_V2", False)
diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py
index a368d09830..2d1c53f62b 100644
--- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py
+++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py
@@ -104,6 +104,32 @@ class Comment(models.Model):
soup = BeautifulSoup(self.body, 'html.parser')
return soup.get_text()
+ @classmethod
+ def retrieve_all(cls, params=None):
+ """
+ Retrieve all comments for a user in a course using Forum v2 API.
+
+ Arguments:
+ params: Dictionary with keys:
+ - user_id: The ID of the user
+ - course_id: The ID of the course
+ - flagged: Boolean for flagged comments
+ - page: Page number
+ - per_page: Items per page
+
+ Returns:
+ Dictionary with collection, comment_count, num_pages, page
+ """
+ if params is None:
+ params = {}
+ return forum_api.get_user_comments(
+ user_id=params.get('user_id'),
+ course_id=params.get('course_id'),
+ flagged=params.get('flagged', False),
+ page=params.get('page', 1),
+ per_page=params.get('per_page', 10),
+ )
+
@classmethod
def get_user_comment_count(cls, user_id, course_ids):
"""
@@ -149,11 +175,3 @@ def _url_for_thread_comments(thread_id):
def _url_for_comment(comment_id):
return f"{settings.PREFIX}/comments/{comment_id}"
-
-
-def _url_for_flag_abuse_comment(comment_id):
- return f"{settings.PREFIX}/comments/{comment_id}/abuse_flag"
-
-
-def _url_for_unflag_abuse_comment(comment_id):
- return f"{settings.PREFIX}/comments/{comment_id}/abuse_unflag"
diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/course.py b/openedx/core/djangoapps/django_comment_common/comment_client/course.py
index 8cbb580e78..1dad0ca159 100644
--- a/openedx/core/djangoapps/django_comment_common/comment_client/course.py
+++ b/openedx/core/djangoapps/django_comment_common/comment_client/course.py
@@ -8,9 +8,6 @@ from edx_django_utils.monitoring import function_trace
from opaque_keys.edx.keys import CourseKey
from forum import api as forum_api
-from openedx.core.djangoapps.django_comment_common.comment_client import settings
-from openedx.core.djangoapps.django_comment_common.comment_client.utils import perform_request
-from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled
def get_course_commentable_counts(course_key: CourseKey) -> Dict[str, Dict[str, int]]:
@@ -31,19 +28,7 @@ def get_course_commentable_counts(course_key: CourseKey) -> Dict[str, Dict[str,
}
"""
- if is_forum_v2_enabled(course_key):
- commentable_stats = forum_api.get_commentables_stats(str(course_key))
- else:
- url = f"{settings.PREFIX}/commentables/{course_key}/counts"
- commentable_stats = perform_request(
- 'get',
- url,
- metric_tags=[
- f"course_key:{course_key}",
- "function:get_course_commentable_counts",
- ],
- metric_action='commentable_stats.retrieve',
- )
+ commentable_stats = forum_api.get_commentables_stats(str(course_key))
return commentable_stats
@@ -81,20 +66,7 @@ def get_course_user_stats(course_key: CourseKey, params: Optional[Dict] = None)
"""
if params is None:
params = {}
- if is_forum_v2_enabled(course_key):
- course_stats = forum_api.get_user_course_stats(str(course_key), **params)
- else:
- url = f"{settings.PREFIX}/users/{course_key}/stats"
- course_stats = perform_request(
- 'get',
- url,
- params,
- metric_action='user.course_stats',
- metric_tags=[
- f"course_key:{course_key}",
- "function:get_course_user_stats",
- ],
- )
+ course_stats = forum_api.get_user_course_stats(str(course_key), **params)
return course_stats
@@ -109,17 +81,5 @@ def update_course_users_stats(course_key: CourseKey) -> Dict:
Returns:
dict: data returned by API. Contains count of users updated.
"""
- if is_forum_v2_enabled(course_key):
- course_stats = forum_api.update_users_in_course(str(course_key))
- else:
- url = f"{settings.PREFIX}/users/{course_key}/update_stats"
- course_stats = perform_request(
- 'post',
- url,
- metric_action='user.update_course_stats',
- metric_tags=[
- f"course_key:{course_key}",
- "function:update_course_users_stats",
- ],
- )
+ course_stats = forum_api.update_users_in_course(str(course_key))
return course_stats
diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/models.py b/openedx/core/djangoapps/django_comment_common/comment_client/models.py
index 4544a463ed..cbb6b25071 100644
--- a/openedx/core/djangoapps/django_comment_common/comment_client/models.py
+++ b/openedx/core/djangoapps/django_comment_common/comment_client/models.py
@@ -2,11 +2,9 @@
import logging
-import typing as t
from .utils import CommentClientRequestError, extract, perform_request, get_course_key
from forum import api as forum_api
-from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally
log = logging.getLogger(__name__)
@@ -74,10 +72,11 @@ class Model:
def _retrieve(self, *args, **kwargs):
course_id = self.attributes.get("course_id") or kwargs.get("course_key")
if not course_id:
- _, course_id = is_forum_v2_enabled_for_comment(self.id)
+ course_id = forum_api.get_course_id_by_comment(self.id)
+ response = None
if self.type == "comment":
response = forum_api.get_parent_comment(comment_id=self.attributes["id"], course_id=course_id)
- else:
+ if response is None:
raise CommentClientRequestError("Forum v2 API call is missing")
self._update_from_response(response)
@@ -206,18 +205,15 @@ class Model:
request_params.update(params)
course_id = self.attributes.get("course_id") or request_params.get("course_id")
course_key = get_course_key(course_id)
- if is_forum_v2_enabled(course_key):
- response = None
- if self.type == "comment":
- response = self.handle_update_comment(request_params, str(course_key))
- elif self.type == "thread":
- response = self.handle_update_thread(request_params, str(course_key))
- elif self.type == "user":
- response = self.handle_update_user(request_params, str(course_key))
- if response is None:
- raise CommentClientRequestError("Forum v2 API call is missing")
- else:
- response = self.perform_http_put_request(request_params)
+ response = None
+ if self.type == "comment":
+ response = self.handle_update_comment(request_params, str(course_key))
+ elif self.type == "thread":
+ response = self.handle_update_thread(request_params, str(course_key))
+ elif self.type == "user":
+ response = self.handle_update_user(request_params, str(course_key))
+ if response is None:
+ raise CommentClientRequestError("Forum v2 API call is missing")
return response
def handle_update_user(self, request_params, course_id):
@@ -274,28 +270,6 @@ class Model:
response = forum_api.update_thread(**request_data)
return response
- def perform_http_put_request(self, request_params):
- url = self.url(action="put", params=self.attributes)
- response = perform_request(
- "put",
- url,
- request_params,
- metric_tags=self._metric_tags,
- metric_action="model.update",
- )
- return response
-
- def perform_http_post_request(self):
- url = self.url(action="post", params=self.attributes)
- response = perform_request(
- "post",
- url,
- self.initializable_attributes(),
- metric_tags=self._metric_tags,
- metric_action="model.insert",
- )
- return response
-
def handle_create(self, params=None):
course_id = self.attributes.get("course_id") or params.get("course_id")
course_key = str(get_course_key(course_id))
@@ -348,22 +322,3 @@ class Model:
response = forum_api.create_thread(**params)
return response
-
-
-def is_forum_v2_enabled_for_comment(comment_id: str) -> tuple[bool, t.Optional[str]]:
- """
- Figure out whether we use forum v2 for a given comment.
-
- See is_forum_v2_enabled_for_thread.
-
- Return:
-
- enabled (bool)
- course_id (str or None)
- """
- if is_forum_v2_disabled_globally():
- return False, None
-
- course_id = forum_api.get_course_id_by_comment(comment_id)
- course_key = get_course_key(course_id)
- return is_forum_v2_enabled(course_key), course_id
diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py
index b884352ce3..ffb9147aca 100644
--- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py
+++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py
@@ -3,19 +3,12 @@
import logging
import time
-import typing as t
from eventtracking import tracker
-from django.core.exceptions import ObjectDoesNotExist
from forum import api as forum_api
-from forum.api.threads import prepare_thread_api_response
-from forum.backend import get_backend
-from forum.backends.mongodb.threads import CommentThread
-from forum.utils import ForumV2RequestError
-from rest_framework.serializers import ValidationError
+from forum.backends.mongodb.threads import CommentThread as ForumThread
-from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally
from . import models, settings, utils
@@ -169,7 +162,7 @@ class Thread(models.Model):
request_params = utils.clean_forum_params(request_params)
course_id = kwargs.get("course_id")
if not course_id:
- _, course_id = is_forum_v2_enabled_for_thread(self.id)
+ course_id = forum_api.get_course_id_by_thread(self.id)
if user_id := request_params.get('user_id'):
request_params['user_id'] = str(user_id)
response = forum_api.get_thread(
@@ -228,7 +221,7 @@ class Thread(models.Model):
@classmethod
def get_user_threads_count(cls, user_id, course_ids):
"""
- Returns threads and responses count of user in the given course_ids.
+ Returns threads count of user in the given course_ids.
TODO: Add support for MySQL backend as well
"""
query_params = {
@@ -236,51 +229,7 @@ class Thread(models.Model):
"author_id": str(user_id),
"_type": "CommentThread"
}
- return CommentThread()._collection.count_documents(query_params) # pylint: disable=protected-access
-
- @classmethod
- def _delete_thread(cls, thread_id, course_id=None):
- """
- Deletes a thread
- """
- prefix = "<>"
- backend = get_backend(course_id)()
- try:
- start_time = time.perf_counter()
- thread = backend.validate_object("CommentThread", thread_id)
- log.info(f"{prefix} Thread fetch {time.perf_counter() - start_time} sec")
- except ObjectDoesNotExist as exc:
- log.error("Forumv2RequestError for delete thread request.")
- raise ForumV2RequestError(
- f"Thread does not exist with Id: {thread_id}"
- ) from exc
-
- start_time = time.perf_counter()
- backend.delete_comments_of_a_thread(thread_id)
- log.info(f"{prefix} Delete comments of thread {time.perf_counter() - start_time} sec")
-
- try:
- start_time = time.perf_counter()
- serialized_data = prepare_thread_api_response(thread, backend)
- log.info(f"{prefix} Prepare response {time.perf_counter() - start_time} sec")
- except ValidationError as error:
- log.error(f"Validation error in get_thread: {error}")
- raise ForumV2RequestError("Failed to prepare thread API response") from error
-
- start_time = time.perf_counter()
- backend.delete_subscriptions_of_a_thread(thread_id)
- log.info(f"{prefix} Delete subscriptions {time.perf_counter() - start_time} sec")
-
- start_time = time.perf_counter()
- result = backend.delete_thread(thread_id)
- log.info(f"{prefix} Delete thread {time.perf_counter() - start_time} sec")
- if result and not (thread["anonymous"] or thread["anonymous_to_peers"]):
- start_time = time.perf_counter()
- backend.update_stats_for_course(
- thread["author_id"], thread["course_id"], threads=-1
- )
- log.info(f"{prefix} Update stats {time.perf_counter() - start_time} sec")
- return serialized_data
+ return ForumThread()._collection.count_documents(query_params) # pylint: disable=protected-access
@classmethod
def delete_user_threads(cls, user_id, course_ids):
@@ -294,56 +243,32 @@ class Thread(models.Model):
"author_id": str(user_id),
}
threads_deleted = 0
- threads = CommentThread().get_list(**query_params)
+ threads = ForumThread().get_list(**query_params)
log.info(f"<> Fetched threads for user {user_id} in {time.time() - start_time} seconds")
for thread in threads:
start_time = time.time()
thread_id = thread.get("_id")
course_id = thread.get("course_id")
if thread_id:
- cls._delete_thread(thread_id, course_id=course_id)
+ forum_api.delete_thread(thread_id, course_id=course_id)
threads_deleted += 1
log.info(f"<> Deleted thread {thread_id} in {time.time() - start_time} seconds."
f" Thread Found: {thread_id is not None}")
return threads_deleted
-def _url_for_flag_abuse_thread(thread_id):
- return f"{settings.PREFIX}/threads/{thread_id}/abuse_flag"
-
-
-def _url_for_unflag_abuse_thread(thread_id):
- return f"{settings.PREFIX}/threads/{thread_id}/abuse_unflag"
-
-
-def _url_for_pin_thread(thread_id):
- return f"{settings.PREFIX}/threads/{thread_id}/pin"
-
-
-def _url_for_un_pin_thread(thread_id):
- return f"{settings.PREFIX}/threads/{thread_id}/unpin"
-
-
-def is_forum_v2_enabled_for_thread(thread_id: str) -> tuple[bool, t.Optional[str]]:
- """
- Figure out whether we use forum v2 for a given thread.
-
- This is a complex affair... First, we check the value of the DISABLE_FORUM_V2
- setting, which overrides everything. If this setting does not exist, then we need to
- find the course ID that corresponds to the thread ID. Then, we return the value of
- the course waffle flag for this course ID.
-
- Note that to fetch the course ID associated to a thread ID, we need to connect both
- to mongodb and mysql. As a consequence, when forum v2 needs adequate connection
- strings for both backends.
-
- Return:
-
- enabled (bool)
- course_id (str or None)
- """
- if is_forum_v2_disabled_globally():
- return False, None
- course_id = forum_api.get_course_id_by_thread(thread_id)
- course_key = utils.get_course_key(course_id)
- return is_forum_v2_enabled(course_key), course_id
+def _clean_forum_params(params):
+ """Convert string booleans to actual booleans and remove None values from forum parameters."""
+ result = {}
+ for k, v in params.items():
+ if v is not None:
+ if isinstance(v, str):
+ if v.lower() == 'true':
+ result[k] = True
+ elif v.lower() == 'false':
+ result[k] = False
+ else:
+ result[k] = v
+ else:
+ result[k] = v
+ return result
diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/user.py b/openedx/core/djangoapps/django_comment_common/comment_client/user.py
index 187593e707..0bb49c2d53 100644
--- a/openedx/core/djangoapps/django_comment_common/comment_client/user.py
+++ b/openedx/core/djangoapps/django_comment_common/comment_client/user.py
@@ -4,7 +4,6 @@
from . import models, settings, utils
from forum import api as forum_api
from forum.utils import ForumV2RequestError, str_to_bool
-from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled
class User(models.Model):
@@ -38,17 +37,7 @@ class User(models.Model):
"""
course_id = self.attributes.get("course_id")
course_key = utils.get_course_key(course_id)
- if is_forum_v2_enabled(course_key):
- forum_api.mark_thread_as_read(self.id, source.id, course_id=str(course_id))
- else:
- params = {'source_type': source.type, 'source_id': source.id}
- utils.perform_request(
- 'post',
- _url_for_read(self.id),
- params,
- metric_action='user.read',
- metric_tags=self._metric_tags + [f'target.type:{source.type}'],
- )
+ forum_api.mark_thread_as_read(self.id, source.id, course_id=str(course_id))
def follow(self, source, course_id=None):
course_key = utils.get_course_key(self.attributes.get("course_id") or course_id)
@@ -110,31 +99,21 @@ class User(models.Model):
query_params = {}
if not self.course_id:
raise utils.CommentClientRequestError("Must provide course_id when retrieving active threads for the user")
- url = _url_for_user_active_threads(self.id)
params = {'course_id': str(self.course_id)}
params.update(query_params)
course_key = utils.get_course_key(self.attributes.get("course_id"))
- if is_forum_v2_enabled(course_key):
- if user_id := params.get("user_id"):
- params["user_id"] = str(user_id)
- if page := params.get("page"):
- params["page"] = int(page)
- if per_page := params.get("per_page"):
- params["per_page"] = int(per_page)
- if count_flagged := params.get("count_flagged", False):
- params["count_flagged"] = str_to_bool(count_flagged)
- if not params.get("course_id"):
- params["course_id"] = str(course_key)
- response = forum_api.get_user_active_threads(**params)
- else:
- response = utils.perform_request(
- 'get',
- url,
- params,
- metric_action='user.active_threads',
- metric_tags=self._metric_tags,
- paged_results=True,
- )
+ if user_id := params.get("user_id"):
+ params["user_id"] = str(user_id)
+ if page := params.get("page"):
+ params["page"] = int(page)
+ if per_page := params.get("per_page"):
+ params["per_page"] = int(per_page)
+ if count_flagged := params.get("count_flagged", False):
+ params["count_flagged"] = str_to_bool(count_flagged)
+ if not params.get("course_id"):
+ params["course_id"] = str(course_key)
+ params = _clean_forum_params(params)
+ response = forum_api.get_user_active_threads(**params)
return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1)
def subscribed_threads(self, query_params=None):
@@ -157,9 +136,7 @@ class User(models.Model):
params["count_flagged"] = str_to_bool(count_flagged)
if not params.get("course_id"):
params["course_id"] = str(course_key)
- if 'text' in params:
- params.pop('text')
- params = utils.clean_forum_params(params)
+ params = _clean_forum_params(params)
response = forum_api.get_user_subscriptions(**params)
return utils.CommentClientPaginatedResult(
collection=response.get('collection', []),
@@ -169,7 +146,6 @@ class User(models.Model):
)
def _retrieve(self, *args, **kwargs):
- url = self.url(action='get', params=self.attributes)
retrieve_params = self.default_retrieve_params.copy()
retrieve_params.update(kwargs)
@@ -183,116 +159,43 @@ class User(models.Model):
if course_id:
course_id = str(course_id)
retrieve_params['course_id'] = course_id
- course_key = utils.get_course_key(course_id) or utils.get_course_key(kwargs.get("course_key"))
- if is_forum_v2_enabled(course_key):
- group_ids = [retrieve_params['group_id']] if 'group_id' in retrieve_params else []
- is_complete = retrieve_params['complete']
- params = utils.clean_forum_params({
- "user_id": self.attributes["id"],
- "group_ids": group_ids,
- "course_id": course_id,
- "complete": is_complete
- })
- try:
- response = forum_api.get_user(**params)
- except ForumV2RequestError as e:
- course_id = str(course_key)
- self.save({"course_id": course_id})
- response = forum_api.get_user(**params)
- else:
- try:
- response = utils.perform_request(
- 'get',
- url,
- retrieve_params,
- metric_action='model.retrieve',
- metric_tags=self._metric_tags,
- )
- except utils.CommentClientRequestError as e:
- if e.status_code == 404:
- # attempt to gracefully recover from a previous failure
- # to sync this user to the comments service.
- self.save()
- response = utils.perform_request(
- 'get',
- url,
- retrieve_params,
- metric_action='model.retrieve',
- metric_tags=self._metric_tags,
- )
- else:
- raise
+ group_ids = [retrieve_params['group_id']] if 'group_id' in retrieve_params else None
+ is_complete = retrieve_params['complete']
+ params = _clean_forum_params({
+ "user_id": self.attributes["id"],
+ "group_ids": group_ids,
+ "course_id": course_id,
+ "complete": is_complete
+ })
+ try:
+ response = forum_api.get_user(**params)
+ except ForumV2RequestError as e:
+ self.save({"course_id": course_id})
+ response = forum_api.get_user(**params)
self._update_from_response(response)
def retire(self, retired_username):
course_key = utils.get_course_key(self.attributes.get("course_id"))
- if is_forum_v2_enabled(course_key):
- forum_api.retire_user(user_id=self.id, retired_username=retired_username, course_id=str(course_key))
- else:
- url = _url_for_retire(self.id)
- params = {'retired_username': retired_username}
- utils.perform_request(
- 'post',
- url,
- params,
- raw=True,
- metric_action='user.retire',
- metric_tags=self._metric_tags
- )
+ forum_api.retire_user(user_id=self.id, retired_username=retired_username, course_id=str(course_key))
def replace_username(self, new_username):
course_key = utils.get_course_key(self.attributes.get("course_id"))
- if is_forum_v2_enabled(course_key):
- forum_api.update_username(user_id=self.id, new_username=new_username, course_id=str(course_key))
- else:
- url = _url_for_username_replacement(self.id)
- params = {"new_username": new_username}
-
- utils.perform_request(
- 'post',
- url,
- params,
- raw=True,
- )
+ forum_api.update_username(user_id=self.id, new_username=new_username, course_id=str(course_key))
-def _url_for_vote_comment(comment_id):
- return f"{settings.PREFIX}/comments/{comment_id}/votes"
-
-
-def _url_for_vote_thread(thread_id):
- return f"{settings.PREFIX}/threads/{thread_id}/votes"
-
-
-def _url_for_subscription(user_id):
- return f"{settings.PREFIX}/users/{user_id}/subscriptions"
-
-
-def _url_for_user_active_threads(user_id):
- return f"{settings.PREFIX}/users/{user_id}/active_threads"
-
-
-def _url_for_user_subscribed_threads(user_id):
- return f"{settings.PREFIX}/users/{user_id}/subscribed_threads"
-
-
-def _url_for_read(user_id):
- """
- Returns cs_comments_service url endpoint to mark thread as read for given user_id
- """
- return f"{settings.PREFIX}/users/{user_id}/read"
-
-
-def _url_for_retire(user_id):
- """
- Returns cs_comments_service url endpoint to retire a user (remove all post content, etc.)
- """
- return f"{settings.PREFIX}/users/{user_id}/retire"
-
-
-def _url_for_username_replacement(user_id):
- """
- Returns cs_comments_servuce url endpoint to replace the username of a user
- """
- return f"{settings.PREFIX}/users/{user_id}/replace_username"
+def _clean_forum_params(params):
+ """Convert string booleans to actual booleans and remove None values from forum parameters."""
+ result = {}
+ for k, v in params.items():
+ if v is not None:
+ if isinstance(v, str):
+ if v.lower() == 'true':
+ result[k] = True
+ elif v.lower() == 'false':
+ result[k] = False
+ else:
+ result[k] = v
+ else:
+ result[k] = v
+ return result
diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py b/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py
index d99ac4883b..407a9aac2b 100644
--- a/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py
+++ b/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py
@@ -11,6 +11,7 @@ from edx_rest_framework_extensions.auth.jwt.decoder import (
from jwt.exceptions import ExpiredSignatureError
from common.djangoapps.student.models import UserProfile, anonymous_id_for_user
+from common.test.utils import assert_dict_contains_subset
class AccessTokenMixin:
@@ -88,7 +89,7 @@ class AccessTokenMixin:
expected['grant_type'] = grant_type or ''
- self.assertDictContainsSubset(expected, payload)
+ assert_dict_contains_subset(self, expected, payload)
if expires_in:
assert payload['exp'] == payload['iat'] + expires_in
diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_api.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_api.py
index 3c064cc63c..5bf1f524bc 100644
--- a/openedx/core/djangoapps/oauth_dispatch/tests/test_api.py
+++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_api.py
@@ -9,6 +9,7 @@ from django.test import TestCase
from oauth2_provider.models import AccessToken
from common.djangoapps.student.tests.factories import UserFactory
+from common.test.utils import assert_dict_contains_subset
OAUTH_PROVIDER_ENABLED = settings.FEATURES.get('ENABLE_OAUTH2_PROVIDER')
if OAUTH_PROVIDER_ENABLED:
@@ -43,7 +44,8 @@ class TestOAuthDispatchAPI(TestCase):
token = api.create_dot_access_token(HttpRequest(), self.user, self.client)
assert token['access_token']
assert token['refresh_token']
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
'token_type': 'Bearer',
'expires_in': EXPECTED_DEFAULT_EXPIRES_IN,
@@ -63,5 +65,5 @@ class TestOAuthDispatchAPI(TestCase):
token = api.create_dot_access_token(
HttpRequest(), self.user, self.client, expires_in=expires_in, scopes=['profile'],
)
- self.assertDictContainsSubset({'scope': 'profile'}, token)
- self.assertDictContainsSubset({'expires_in': expires_in}, token)
+ assert_dict_contains_subset(self, {'scope': 'profile'}, token)
+ assert_dict_contains_subset(self, {'expires_in': expires_in}, token)
diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_jwt.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_jwt.py
index da95fd072d..647da14f6e 100644
--- a/openedx/core/djangoapps/oauth_dispatch/tests/test_jwt.py
+++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_jwt.py
@@ -12,6 +12,7 @@ from openedx.core.djangoapps.oauth_dispatch.adapters import DOTAdapter
from openedx.core.djangoapps.oauth_dispatch.models import RestrictedApplication
from openedx.core.djangoapps.oauth_dispatch.tests.mixins import AccessTokenMixin
from common.djangoapps.student.tests.factories import UserFactory
+from common.test.utils import assert_dict_contains_subset
@ddt.ddt
@@ -171,7 +172,7 @@ class TestCreateJWTs(AccessTokenMixin, TestCase):
token_payload = self.assert_valid_jwt_access_token(
jwt_token, self.user, self.default_scopes, aud=aud, secret=secret,
)
- self.assertDictContainsSubset(additional_claims, token_payload)
+ assert_dict_contains_subset(self, additional_claims, token_payload)
assert user_email_verified == token_payload['email_verified']
assert token_payload['roles'] == mock_create_roles.return_value
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_auto_auth.py b/openedx/core/djangoapps/user_authn/views/tests/test_auto_auth.py
index 0346aee25a..b687749030 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_auto_auth.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_auto_auth.py
@@ -21,6 +21,7 @@ from openedx.core.djangoapps.django_comment_common.models import (
)
from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
+from common.test.utils import assert_dict_contains_subset
class AutoAuthTestCase(UrlResetMixin, TestCase):
@@ -182,12 +183,13 @@ class AutoAuthEnabledTestCase(AutoAuthTestCase, ModuleStoreTestCase):
for key in ['created_status', 'username', 'email', 'password', 'user_id', 'anonymous_id']:
assert key in response_data
user = User.objects.get(username=response_data['username'])
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
'created_status': 'Logged in',
'anonymous_id': anonymous_id_for_user(user, None),
},
- response_data
+ response_data,
)
@ddt.data(*COURSE_IDS_DDT)
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_events.py b/openedx/core/djangoapps/user_authn/views/tests/test_events.py
index acec4a935d..7efd4e4cf5 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_events.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_events.py
@@ -18,6 +18,7 @@ from openedx_events.tests.utils import OpenEdxEventsTestMixin
from common.djangoapps.student.tests.factories import UserFactory, UserProfileFactory
from openedx.core.djangoapps.user_api.tests.test_views import UserAPITestCase
from openedx.core.djangolib.testing.utils import skip_unless_lms
+from common.test.utils import assert_dict_contains_subset
@skip_unless_lms
@@ -83,7 +84,8 @@ class RegistrationEventTest(UserAPITestCase, OpenEdxEventsTestMixin):
user = User.objects.get(username=self.user_info.get("username"))
self.assertTrue(self.receiver_called)
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
"signal": STUDENT_REGISTRATION_COMPLETED,
"sender": None,
@@ -97,7 +99,7 @@ class RegistrationEventTest(UserAPITestCase, OpenEdxEventsTestMixin):
is_active=user.is_active,
),
},
- event_receiver.call_args.kwargs
+ event_receiver.call_args.kwargs,
)
@@ -165,7 +167,8 @@ class LoginSessionEventTest(UserAPITestCase, OpenEdxEventsTestMixin):
user = User.objects.get(username=self.user.username)
self.assertTrue(self.receiver_called)
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
"signal": SESSION_LOGIN_COMPLETED,
"sender": None,
@@ -179,5 +182,5 @@ class LoginSessionEventTest(UserAPITestCase, OpenEdxEventsTestMixin):
is_active=user.is_active,
),
},
- event_receiver.call_args.kwargs
+ event_receiver.call_args.kwargs,
)
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py
index b00702ee25..c8bfa08290 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py
@@ -44,6 +44,7 @@ from openedx.core.lib.api.test_utils import ApiTestCase
from openedx.features.enterprise_support.tests.factories import EnterpriseCustomerUserFactory
from common.djangoapps.student.models import LoginFailures
from common.djangoapps.util.password_policy_validators import DEFAULT_MAX_PASSWORD_LENGTH
+from common.test.utils import assert_dict_contains_subset
@ddt.ddt
@@ -544,7 +545,7 @@ class LoginTest(SiteMixin, CacheIsolationTestCase, OpenEdxEventsTestMixin):
expected = {
'target': '/',
}
- self.assertDictContainsSubset(expected, response.context_data)
+ assert_dict_contains_subset(self, expected, response.context_data)
@patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True})
def test_logout_logging_no_pii(self):
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_logout.py b/openedx/core/djangoapps/user_authn/views/tests/test_logout.py
index 77c21c86e1..a81d11c42c 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_logout.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_logout.py
@@ -14,6 +14,7 @@ from django.urls import reverse
from openedx.core.djangoapps.oauth_dispatch.tests.factories import ApplicationFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
from common.djangoapps.student.tests.factories import UserFactory
+from common.test.utils import assert_dict_contains_subset
@skip_unless_lms
@@ -76,14 +77,14 @@ class LogoutTests(TestCase):
expected = {
'target': urllib.parse.unquote(redirect_url),
}
- self.assertDictContainsSubset(expected, response.context_data)
+ assert_dict_contains_subset(self, expected, response.context_data)
def test_no_redirect_supplied(self):
response = self.client.get(reverse('logout'), HTTP_HOST='testserver')
expected = {
'target': '/',
}
- self.assertDictContainsSubset(expected, response.context_data)
+ assert_dict_contains_subset(self, expected, response.context_data)
@ddt.data(
('https://www.amazon.org', 'edx.org'),
@@ -100,7 +101,7 @@ class LogoutTests(TestCase):
expected = {
'target': '/',
}
- self.assertDictContainsSubset(expected, response.context_data)
+ assert_dict_contains_subset(self, expected, response.context_data)
def test_client_logout(self):
""" Verify the context includes a list of the logout URIs of the authenticated OpenID Connect clients.
@@ -113,7 +114,7 @@ class LogoutTests(TestCase):
'logout_uris': [],
'target': '/',
}
- self.assertDictContainsSubset(expected, response.context_data)
+ assert_dict_contains_subset(self, expected, response.context_data)
@mock.patch(
'django.conf.settings.IDA_LOGOUT_URI_LIST',
@@ -138,7 +139,7 @@ class LogoutTests(TestCase):
'logout_uris': expected_logout_uris,
'target': '/',
}
- self.assertDictContainsSubset(expected, response.context_data)
+ assert_dict_contains_subset(self, expected, response.context_data)
@mock.patch(
'django.conf.settings.IDA_LOGOUT_URI_LIST',
@@ -161,7 +162,7 @@ class LogoutTests(TestCase):
'logout_uris': expected_logout_uris,
'target': '/',
}
- self.assertDictContainsSubset(expected, response.context_data)
+ assert_dict_contains_subset(self, expected, response.context_data)
def test_filter_referring_service(self):
""" Verify that, if the user is directed to the logout page from a service, that service's logout URL
@@ -174,7 +175,7 @@ class LogoutTests(TestCase):
'target': '/',
'show_tpa_logout_link': False,
}
- self.assertDictContainsSubset(expected, response.context_data)
+ assert_dict_contains_subset(self, expected, response.context_data)
def test_learner_portal_logout_having_idp_logout_url(self):
"""
@@ -194,7 +195,7 @@ class LogoutTests(TestCase):
'tpa_logout_url': idp_logout_url,
'show_tpa_logout_link': True,
}
- self.assertDictContainsSubset(expected, response.context_data)
+ assert_dict_contains_subset(self, expected, response.context_data)
@mock.patch('django.conf.settings.TPA_AUTOMATIC_LOGOUT_ENABLED', True)
def test_automatic_tpa_logout_url_redirect(self):
@@ -214,7 +215,7 @@ class LogoutTests(TestCase):
expected = {
'target': idp_logout_url,
}
- self.assertDictContainsSubset(expected, response.context_data)
+ assert_dict_contains_subset(self, expected, response.context_data)
@mock.patch('django.conf.settings.TPA_AUTOMATIC_LOGOUT_ENABLED', True)
def test_no_automatic_tpa_logout_without_logout_url(self):
@@ -241,4 +242,4 @@ class LogoutTests(TestCase):
expected = {
'target': nh3.clean(urllib.parse.unquote(redirect_url)),
}
- self.assertDictContainsSubset(expected, response.context_data)
+ assert_dict_contains_subset(self, expected, response.context_data)
diff --git a/xmodule/video_block/sharing_sites.py b/openedx/core/djangoapps/video_config/sharing_sites.py
similarity index 100%
rename from xmodule/video_block/sharing_sites.py
rename to openedx/core/djangoapps/video_config/sharing_sites.py
diff --git a/openedx/core/lib/tests/test_user_util.py b/openedx/core/lib/tests/test_user_util.py
new file mode 100644
index 0000000000..48d7941aa0
--- /dev/null
+++ b/openedx/core/lib/tests/test_user_util.py
@@ -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',
+ 'wdwhs@(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
diff --git a/openedx/core/lib/user_util.py b/openedx/core/lib/user_util.py
new file mode 100644
index 0000000000..c15a2bac14
--- /dev/null
+++ b/openedx/core/lib/user_util.py
@@ -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])
+ )
diff --git a/openedx/core/process_warnings.py b/openedx/core/process_warnings.py
index 6f695fa277..712f0f1ea8 100644
--- a/openedx/core/process_warnings.py
+++ b/openedx/core/process_warnings.py
@@ -91,7 +91,7 @@ def read_warning_data(dir_path):
# TODO(jinder): currently this is hard-coded in, maybe create a constants file with info
# THINK(jinder): but creating file for one constant seems overkill
warnings_file_name_regex = (
- r"pytest_warnings_?[\w-]*\.json" # noqa pylint: disable=W1401
+ r"pytest_warnings_?[\w.-]*\.json" # noqa pylint: disable=W1401
)
# iterate through files_in_dir and see if they match our know file name pattern
diff --git a/openedx/envs/common.py b/openedx/envs/common.py
index 1e89d2a38c..ada6fd3f0b 100644
--- a/openedx/envs/common.py
+++ b/openedx/envs/common.py
@@ -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 = {
diff --git a/openedx/features/announcements/__init__.py b/openedx/features/announcements/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/openedx/features/announcements/apps.py b/openedx/features/announcements/apps.py
deleted file mode 100644
index 4bf964cae5..0000000000
--- a/openedx/features/announcements/apps.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""
-Announcements Application Configuration
-"""
-
-
-from django.apps import AppConfig
-from edx_django_utils.plugins import PluginURLs, PluginSettings
-
-from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
-
-
-class AnnouncementsConfig(AppConfig):
- """
- Application Configuration for Announcements
- """
- name = 'openedx.features.announcements'
-
- plugin_app = {
- PluginURLs.CONFIG: {
- ProjectType.LMS: {
- PluginURLs.NAMESPACE: 'announcements',
- PluginURLs.REGEX: '^announcements/',
- PluginURLs.RELATIVE_PATH: 'urls',
- }
- },
- PluginSettings.CONFIG: {
- ProjectType.LMS: {
- SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: 'settings.common'},
- SettingsType.TEST: {PluginSettings.RELATIVE_PATH: 'settings.test'},
- }
- }
- }
diff --git a/openedx/features/announcements/forms.py b/openedx/features/announcements/forms.py
deleted file mode 100644
index 879101ca37..0000000000
--- a/openedx/features/announcements/forms.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""
-Forms for the Announcement Editor
-"""
-
-
-from django import forms
-
-from .models import Announcement
-
-
-class AnnouncementForm(forms.ModelForm):
- """
- Form for editing Announcements
- """
- content = forms.CharField(widget=forms.Textarea, label='', required=False)
- active = forms.BooleanField(initial=True, required=False)
-
- class Meta:
- model = Announcement
- fields = ['content', 'active']
diff --git a/openedx/features/announcements/migrations/0001_initial.py b/openedx/features/announcements/migrations/0001_initial.py
deleted file mode 100644
index c959b63490..0000000000
--- a/openedx/features/announcements/migrations/0001_initial.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ]
-
- operations = [
- migrations.CreateModel(
- name='Announcement',
- fields=[
- ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
- ('content', models.CharField(default='lorem ipsum', max_length=1000)),
- ('active', models.BooleanField(default=True)),
- ],
- ),
- ]
diff --git a/openedx/features/announcements/migrations/__init__.py b/openedx/features/announcements/migrations/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/openedx/features/announcements/models.py b/openedx/features/announcements/models.py
deleted file mode 100644
index f58f61165d..0000000000
--- a/openedx/features/announcements/models.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""
-Models for Announcements
-"""
-
-
-from django.db import models
-
-
-class Announcement(models.Model):
- """
- Site-wide announcements to be displayed on the dashboard
-
- .. no_pii:
- """
- class Meta:
- app_label = 'announcements'
-
- content = models.CharField(max_length=1000, null=False, default="lorem ipsum")
- active = models.BooleanField(default=True)
-
- def __str__(self):
- return self.content
diff --git a/openedx/features/announcements/settings/__init__.py b/openedx/features/announcements/settings/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/openedx/features/announcements/settings/common.py b/openedx/features/announcements/settings/common.py
deleted file mode 100644
index 4de3740d2d..0000000000
--- a/openedx/features/announcements/settings/common.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""Common settings for Announcements"""
-
-
-def plugin_settings(settings):
- """
- Common settings for Announcements
- .. toggle_name: FEATURES['ENABLE_ANNOUNCEMENTS']
- .. toggle_implementation: SettingDictToggle
- .. toggle_default: False
- .. toggle_description: This feature can be enabled to show system wide announcements
- on the sidebar of the learner dashboard. Announcements can be created by Global Staff
- users on maintenance dashboard of studio. Maintenance dashboard can accessed at
- https://{studio.domain}/maintenance
- .. toggle_warning: TinyMCE is needed to show an editor in the studio.
- .. toggle_use_cases: open_edx
- .. toggle_creation_date: 2017-11-08
- .. toggle_tickets: https://github.com/openedx/edx-platform/pull/16496
- """
- settings.ENABLE_ANNOUNCEMENTS = False
- # Configure number of announcements to show per page
- settings.ANNOUNCEMENTS_PER_PAGE = 5
diff --git a/openedx/features/announcements/settings/test.py b/openedx/features/announcements/settings/test.py
deleted file mode 100644
index 8c8406d23f..0000000000
--- a/openedx/features/announcements/settings/test.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""Test settings for Announcements"""
-
-
-def plugin_settings(settings):
- """
- Test settings for Announcements
- """
- settings.ENABLE_ANNOUNCEMENTS = True
diff --git a/openedx/features/announcements/static/announcements/jsx/Announcements.jsx b/openedx/features/announcements/static/announcements/jsx/Announcements.jsx
deleted file mode 100644
index 9d37088335..0000000000
--- a/openedx/features/announcements/static/announcements/jsx/Announcements.jsx
+++ /dev/null
@@ -1,141 +0,0 @@
-// eslint-disable-next-line max-classes-per-file
-import React from 'react';
-import ReactDOM from 'react-dom';
-import PropTypes from 'prop-types';
-import {Button} from '@edx/paragon';
-import $ from 'jquery';
-
-class AnnouncementSkipLink extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- count: 0
- };
- $.get('/announcements/page/1')
- .then(data => {
- this.setState({
- count: data.count
- });
- });
- }
-
- render() {
- return ({'Skip to list of ' + this.state.count + ' announcements'}
);
- }
-}
-
-// eslint-disable-next-line react/prefer-stateless-function
-class Announcement extends React.Component {
- render() {
- return (
-
- );
- }
-}
-
-Announcement.propTypes = {
- content: PropTypes.string.isRequired,
-};
-
-class AnnouncementList extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- page: 1,
- announcements: [],
- // eslint-disable-next-line react/no-unused-state
- num_pages: 0,
- has_prev: false,
- has_next: false,
- start_index: 0,
- end_index: 0,
- };
- }
-
- retrievePage(page) {
- $.get('/announcements/page/' + page)
- .then(data => {
- this.setState({
- announcements: data.announcements,
- has_next: data.next,
- has_prev: data.prev,
- // eslint-disable-next-line react/no-unused-state
- num_pages: data.num_pages,
- count: data.count,
- start_index: data.start_index,
- end_index: data.end_index,
- page: page
- });
- });
- }
-
- renderPrevPage() {
- this.retrievePage(this.state.page - 1);
- }
-
- renderNextPage() {
- this.retrievePage(this.state.page + 1);
- }
-
- // eslint-disable-next-line react/no-deprecated, react/sort-comp
- componentWillMount() {
- this.retrievePage(this.state.page);
- }
-
- render() {
- var children = this.state.announcements.map(
- // eslint-disable-next-line react/no-array-index-key
- (announcement, index) =>
- );
- if (this.state.has_prev) {
- var prev_button = (
-
-
- );
- }
- if (this.state.has_next) {
- var next_button = (
-
-
- );
- }
- return (
-
- {children}
- {prev_button}
- {next_button}
-
- );
- }
-}
-
-export default class AnnouncementsView {
- constructor() {
- ReactDOM.render(
- ,
- document.getElementById('announcements'),
- );
- ReactDOM.render(
- ,
- document.getElementById('announcements-skip'),
- );
- }
-}
-
-export {AnnouncementsView, AnnouncementList, AnnouncementSkipLink};
diff --git a/openedx/features/announcements/static/announcements/jsx/Announcements.test.jsx b/openedx/features/announcements/static/announcements/jsx/Announcements.test.jsx
deleted file mode 100644
index 3ec55f3928..0000000000
--- a/openedx/features/announcements/static/announcements/jsx/Announcements.test.jsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from 'react';
-import renderer from 'react-test-renderer';
-import testAnnouncements from './test-announcements.json';
-
-import {AnnouncementSkipLink, AnnouncementList} from './Announcements';
-
-describe('Announcements component', () => {
- test('render skip link', () => {
- const component = renderer.create(
- ,
- );
- component.root.instance.setState({count: 10});
- const tree = component.toJSON();
- expect(tree).toMatchSnapshot();
- });
-
- test('render test announcements', () => {
- const component = renderer.create(
- ,
- );
- component.root.instance.setState(testAnnouncements);
- const tree = component.toJSON();
- expect(tree).toMatchSnapshot();
- });
-});
diff --git a/openedx/features/announcements/static/announcements/jsx/__snapshots__/Announcements.test.jsx.snap b/openedx/features/announcements/static/announcements/jsx/__snapshots__/Announcements.test.jsx.snap
deleted file mode 100644
index bbf9bfaaaa..0000000000
--- a/openedx/features/announcements/static/announcements/jsx/__snapshots__/Announcements.test.jsx.snap
+++ /dev/null
@@ -1,78 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Announcements component render skip link 1`] = `
-
- Skip to list of 10 announcements
-
-`;
-
-exports[`Announcements component render test announcements 1`] = `
-
-
-
Announcement 2",
- }
- }
- />
-
-
-
-
-
-
-
- 1 - 5) of 6
-
-
-
-`;
diff --git a/openedx/features/announcements/static/announcements/jsx/test-announcements.json b/openedx/features/announcements/static/announcements/jsx/test-announcements.json
deleted file mode 100644
index d23d393030..0000000000
--- a/openedx/features/announcements/static/announcements/jsx/test-announcements.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "announcements": [
- {"content": "Test Announcement 1"},
- {"content": "Bold
Announcement 2"},
- {"content": "Test Announcement 3"},
- {"content": "Test Announcement 4"},
- {"content": "Test Announcement 5"},
- {"content": "Test Announcement 6"}
- ],
- "has_next": true,
- "has_prev": false,
- "num_pages": 2,
- "count": 6,
- "start_index": 1,
- "end_index": 5,
- "page": 1
-}
diff --git a/openedx/features/announcements/tests/__init__.py b/openedx/features/announcements/tests/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/openedx/features/announcements/tests/test_announcements.py b/openedx/features/announcements/tests/test_announcements.py
deleted file mode 100644
index 10c608b4a6..0000000000
--- a/openedx/features/announcements/tests/test_announcements.py
+++ /dev/null
@@ -1,95 +0,0 @@
-"""
-Unit tests for the announcements feature.
-"""
-
-import json
-from unittest.mock import patch
-
-from django.conf import settings
-from django.test import TestCase
-from django.test.client import Client
-from django.urls import reverse
-
-from common.djangoapps.student.tests.factories import AdminFactory
-from openedx.core.djangolib.testing.utils import skip_unless_lms
-from openedx.features.announcements.models import Announcement
-
-TEST_ANNOUNCEMENTS = [
- ("Active Announcement", True),
- ("Inactive Announcement", False),
- ("Another Test Announcement", True),
- ("
Formatted Announcement", True),
- ("
Other Formatted Announcement", True),
-]
-
-
-@skip_unless_lms
-class TestGlobalAnnouncements(TestCase):
- """
- Test Announcements in LMS
- """
-
- @classmethod
- def setUpTestData(cls):
- super().setUpTestData()
- Announcement.objects.bulk_create([
- Announcement(content=content, active=active)
- for content, active in TEST_ANNOUNCEMENTS
- ])
-
- def setUp(self):
- super().setUp()
- self.client = Client()
- self.admin = AdminFactory.create(
- email='staff@edx.org',
- username='admin',
- password='pass'
- )
- self.client.login(username=self.admin.username, password='pass')
-
- @patch.dict(settings.FEATURES, {'ENABLE_ANNOUNCEMENTS': False})
- def test_feature_flag_disabled(self):
- """Ensures that the default settings effectively disables the feature"""
- response = self.client.get('/dashboard')
- self.assertNotContains(response, 'AnnouncementsView')
- self.assertNotContains(response, '
Formatted Announcement")
diff --git a/openedx/features/announcements/urls.py b/openedx/features/announcements/urls.py
deleted file mode 100644
index 0f0ad3a339..0000000000
--- a/openedx/features/announcements/urls.py
+++ /dev/null
@@ -1,13 +0,0 @@
-"""
-Defines URLs for announcements in the LMS.
-"""
-from django.contrib.auth.decorators import login_required
-from django.urls import path
-
-from .views import AnnouncementsJSONView
-
-urlpatterns = [
- path('page/
', login_required(AnnouncementsJSONView.as_view()),
- name='page',
- ),
-]
diff --git a/openedx/features/announcements/views.py b/openedx/features/announcements/views.py
deleted file mode 100644
index b6657c29cc..0000000000
--- a/openedx/features/announcements/views.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""
-Views to show announcements.
-"""
-
-
-from django.conf import settings
-from django.http import JsonResponse
-from django.views.generic.list import ListView
-
-from .models import Announcement
-
-
-class AnnouncementsJSONView(ListView):
- """
- View returning a page of announcements for the dashboard
- """
- model = Announcement
- object_list = Announcement.objects.filter(active=True)
- paginate_by = settings.FEATURES.get('ANNOUNCEMENTS_PER_PAGE', 5)
-
- def get(self, request, *args, **kwargs):
- """
- Return active announcements as json
- """
- context = self.get_context_data()
-
- announcements = [{"content": announcement.content} for announcement in context['object_list']]
- result = {
- "announcements": announcements,
- "next": context['page_obj'].has_next(),
- "prev": context['page_obj'].has_previous(),
- "start_index": context['page_obj'].start_index(),
- "end_index": context['page_obj'].end_index(),
- "count": context['paginator'].count,
- "num_pages": context['paginator'].num_pages,
- }
- return JsonResponse(result)
diff --git a/openedx/features/course_experience/api/v1/tests/test_utils.py b/openedx/features/course_experience/api/v1/tests/test_utils.py
new file mode 100644
index 0000000000..741a2d7658
--- /dev/null
+++ b/openedx/features/course_experience/api/v1/tests/test_utils.py
@@ -0,0 +1,117 @@
+"""
+Tests utils of course expirience feature.
+"""
+import datetime
+
+from django.urls import reverse
+from django.utils import timezone
+from rest_framework.test import APIRequestFactory
+
+from common.djangoapps.course_modes.models import CourseMode
+from common.djangoapps.student.models import CourseEnrollment
+from common.djangoapps.util.testing import EventTestMixin
+from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests
+from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin
+from openedx.core.djangoapps.schedules.models import Schedule
+from openedx.features.course_experience.api.v1.utils import (
+ reset_deadlines_for_course,
+ reset_course_deadlines_for_user,
+ reset_bulk_course_deadlines
+)
+from xmodule.modulestore.tests.factories import CourseFactory
+
+
+class TestResetDeadlinesForCourse(EventTestMixin, BaseCourseHomeTests, MasqueradeMixin):
+ """
+ Tests for reset deadlines endpoint.
+ """
+ def setUp(self): # pylint: disable=arguments-differ
+ super().setUp("openedx.features.course_experience.api.v1.utils.tracker")
+ self.course = CourseFactory.create(self_paced=True, start=timezone.now() - datetime.timedelta(days=1000))
+
+ def test_reset_deadlines_for_course(self):
+ enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED)
+ enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=100)
+ enrollment.schedule.save()
+
+ request = APIRequestFactory().post(
+ reverse("course-experience-reset-course-deadlines"), {"course_key": self.course.id}
+ )
+ request.user = self.user
+
+ reset_deadlines_for_course(request, self.course.id, {})
+
+ assert enrollment.schedule.start_date < Schedule.objects.get(id=enrollment.schedule.id).start_date
+ self.assert_event_emitted(
+ "edx.ui.lms.reset_deadlines.clicked",
+ courserun_key=str(self.course.id),
+ is_masquerading=False,
+ is_staff=False,
+ org_key=self.course.org,
+ user_id=self.user.id,
+ )
+
+ def test_reset_deadlines_with_masquerade(self):
+ """Staff users should be able to masquerade as a learner and reset the learner's schedule"""
+ student_username = self.user.username
+ student_user_id = self.user.id
+ student_enrollment = CourseEnrollment.enroll(self.user, self.course.id)
+ student_enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=100)
+ student_enrollment.schedule.save()
+
+ staff_enrollment = CourseEnrollment.enroll(self.staff_user, self.course.id)
+ staff_enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=30)
+ staff_enrollment.schedule.save()
+
+ self.switch_to_staff()
+ self.update_masquerade(course=self.course, username=student_username)
+
+ request = APIRequestFactory().post(
+ reverse("course-experience-reset-course-deadlines"), {"course_key": self.course.id}
+ )
+ request.user = self.staff_user
+ request.session = self.client.session
+
+ reset_deadlines_for_course(request, self.course.id, {})
+
+ updated_schedule = Schedule.objects.get(id=student_enrollment.schedule.id)
+ assert updated_schedule.start_date.date() == datetime.datetime.today().date()
+ updated_staff_schedule = Schedule.objects.get(id=staff_enrollment.schedule.id)
+ assert updated_staff_schedule.start_date == staff_enrollment.schedule.start_date
+ self.assert_event_emitted(
+ "edx.ui.lms.reset_deadlines.clicked",
+ courserun_key=str(self.course.id),
+ is_masquerading=True,
+ is_staff=False,
+ org_key=self.course.org,
+ user_id=student_user_id,
+ )
+
+ def test_reset_course_deadlines_for_user(self):
+ """Test the reset_course_deadlines_for_user utility function directly"""
+ enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED)
+ enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=100)
+ enrollment.schedule.save()
+
+ result = reset_course_deadlines_for_user(self.user, self.course.id)
+
+ assert result is True
+ assert enrollment.schedule.start_date < Schedule.objects.get(id=enrollment.schedule.id).start_date
+
+ def test_reset_bulk_course_deadlines(self):
+ """Test the reset_bulk_course_deadlines utility function"""
+ enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED)
+ enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=100)
+ enrollment.schedule.save()
+
+ request = APIRequestFactory().post(
+ reverse("course-experience-reset-all-course-deadlines"), {}
+ )
+ request.user = self.user
+
+ success_keys, failed_keys = reset_bulk_course_deadlines(request, [self.course.id], {})
+
+ assert len(success_keys) == 1
+ assert self.course.id in success_keys
+ assert len(failed_keys) == 0
+ assert enrollment.schedule.start_date < Schedule.objects.get(id=enrollment.schedule.id).start_date
diff --git a/openedx/features/course_experience/api/v1/tests/test_views.py b/openedx/features/course_experience/api/v1/tests/test_views.py
index 8cef39053b..097eb2c18c 100644
--- a/openedx/features/course_experience/api/v1/tests/test_views.py
+++ b/openedx/features/course_experience/api/v1/tests/test_views.py
@@ -1,7 +1,9 @@
"""
Tests for reset deadlines endpoint.
"""
+
import datetime
+from unittest import mock
import ddt
from django.urls import reverse
@@ -10,7 +12,6 @@ from edx_toggles.toggles.testutils import override_waffle_flag
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import CourseEnrollment
-from common.djangoapps.util.testing import EventTestMixin
from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests
from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin
from openedx.core.djangoapps.schedules.models import Schedule
@@ -19,14 +20,12 @@ from xmodule.modulestore.tests.factories import CourseFactory
@ddt.ddt
-class ResetCourseDeadlinesViewTests(EventTestMixin, BaseCourseHomeTests, MasqueradeMixin):
+class ResetCourseDeadlinesViewTests(BaseCourseHomeTests, MasqueradeMixin):
"""
Tests for reset deadlines endpoint.
"""
def setUp(self): # pylint: disable=arguments-differ
- # Need to supply tracker name for the EventTestMixin. Also, EventTestMixin needs to come
- # first in class inheritance so the setUp call here appropriately works
- super().setUp('openedx.features.course_experience.api.v1.views.tracker')
+ super().setUp()
self.course = CourseFactory.create(self_paced=True, start=timezone.now() - datetime.timedelta(days=1000))
def test_reset_deadlines(self):
@@ -37,20 +36,11 @@ class ResetCourseDeadlinesViewTests(EventTestMixin, BaseCourseHomeTests, Masquer
response = self.client.post(reverse('course-experience-reset-course-deadlines'), {'course': self.course.id})
assert response.status_code == 400
assert enrollment.schedule == Schedule.objects.get(id=enrollment.schedule.id)
- self.assert_no_events_were_emitted()
# Test correct post body
response = self.client.post(reverse('course-experience-reset-course-deadlines'), {'course_key': self.course.id})
assert response.status_code == 200
assert enrollment.schedule.start_date < Schedule.objects.get(id=enrollment.schedule.id).start_date
- self.assert_event_emitted(
- 'edx.ui.lms.reset_deadlines.clicked',
- courserun_key=str(self.course.id),
- is_masquerading=False,
- is_staff=False,
- org_key=self.course.org,
- user_id=self.user.id,
- )
@override_waffle_flag(RELATIVE_DATES_FLAG, active=True)
@override_waffle_flag(RELATIVE_DATES_DISABLE_RESET_FLAG, active=True)
@@ -62,36 +52,6 @@ class ResetCourseDeadlinesViewTests(EventTestMixin, BaseCourseHomeTests, Masquer
response = self.client.post(reverse('course-experience-reset-course-deadlines'), {'course_key': self.course.id})
assert response.status_code == 200
assert enrollment.schedule == Schedule.objects.get(id=enrollment.schedule.id)
- self.assert_no_events_were_emitted()
-
- def test_reset_deadlines_with_masquerade(self):
- """ Staff users should be able to masquerade as a learner and reset the learner's schedule """
- student_username = self.user.username
- student_user_id = self.user.id
- student_enrollment = CourseEnrollment.enroll(self.user, self.course.id)
- student_enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=100)
- student_enrollment.schedule.save()
-
- staff_enrollment = CourseEnrollment.enroll(self.staff_user, self.course.id)
- staff_enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=30)
- staff_enrollment.schedule.save()
-
- self.switch_to_staff()
- self.update_masquerade(course=self.course, username=student_username)
-
- self.client.post(reverse('course-experience-reset-course-deadlines'), {'course_key': self.course.id})
- updated_schedule = Schedule.objects.get(id=student_enrollment.schedule.id)
- assert updated_schedule.start_date.date() == datetime.datetime.today().date()
- updated_staff_schedule = Schedule.objects.get(id=staff_enrollment.schedule.id)
- assert updated_staff_schedule.start_date == staff_enrollment.schedule.start_date
- self.assert_event_emitted(
- 'edx.ui.lms.reset_deadlines.clicked',
- courserun_key=str(self.course.id),
- is_masquerading=True,
- is_staff=False,
- org_key=self.course.org,
- user_id=student_user_id,
- )
def test_post_unauthenticated_user(self):
self.client.logout()
@@ -115,3 +75,52 @@ class ResetCourseDeadlinesViewTests(EventTestMixin, BaseCourseHomeTests, Masquer
self.client.logout()
response = self.client.get(reverse('course-experience-course-deadlines-mobile', args=[self.course.id]))
assert response.status_code == 401
+
+
+class ResetAllRelativeCourseDeadlinesViewTests(BaseCourseHomeTests, MasqueradeMixin):
+ """
+ Tests for reset all relative deadlines endpoint.
+ """
+
+ def setUp(self): # pylint: disable=arguments-differ
+ super().setUp()
+ self.course = CourseFactory.create(self_paced=True, start=timezone.now() - datetime.timedelta(days=1000))
+ self.enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED)
+ self.enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=100)
+ self.enrollment.schedule.save()
+
+ def test_reset_all_course_deadlines(self):
+ """
+ Test reset all course deadlines endpoint
+ """
+ response = self.client.post(
+ reverse("course-experience-reset-all-course-deadlines"),
+ {},
+ )
+ assert response.status_code == 200
+ assert self.enrollment.schedule.start_date < Schedule.objects.get(id=self.enrollment.schedule.id).start_date
+ assert str(self.course.id) in response.data.get("success_course_keys")
+
+ def test_reset_all_course_deadlines_failure(self):
+ """
+ Raise exception on reset_bulk_course_deadlines and assert if failure course id is returned
+ """
+ with mock.patch(
+ "openedx.features.course_experience.api.v1.views.reset_bulk_course_deadlines",
+ return_value=([], [self.course.id]),
+ ):
+ response = self.client.post(reverse("course-experience-reset-all-course-deadlines"), {})
+
+ assert response.status_code == 200
+ assert str(self.course.id) in response.data.get("failed_course_keys")
+
+ def test_post_unauthenticated_user(self):
+ """
+ Test reset all relative course deadlines endpoint for unauthenticated user
+ """
+ self.client.logout()
+ response = self.client.post(
+ reverse("course-experience-reset-all-course-deadlines"),
+ {},
+ )
+ assert response.status_code == 401
diff --git a/openedx/features/course_experience/api/v1/urls.py b/openedx/features/course_experience/api/v1/urls.py
index 9a2c7106cd..2c84af437f 100644
--- a/openedx/features/course_experience/api/v1/urls.py
+++ b/openedx/features/course_experience/api/v1/urls.py
@@ -4,9 +4,13 @@ Contains URLs for the Course Experience API
from django.conf import settings
-from django.urls import re_path
+from django.urls import re_path, path
-from openedx.features.course_experience.api.v1.views import reset_course_deadlines, CourseDeadlinesMobileView
+from openedx.features.course_experience.api.v1.views import (
+ reset_course_deadlines,
+ reset_all_course_deadlines,
+ CourseDeadlinesMobileView,
+)
urlpatterns = []
@@ -17,6 +21,11 @@ urlpatterns += [
reset_course_deadlines,
name='course-experience-reset-course-deadlines'
),
+ path(
+ 'v1/reset_all_course_deadlines/',
+ reset_all_course_deadlines,
+ name='course-experience-reset-all-course-deadlines',
+ )
]
# URL for retrieving course deadlines info
diff --git a/openedx/features/course_experience/api/v1/utils.py b/openedx/features/course_experience/api/v1/utils.py
new file mode 100644
index 0000000000..8f9205b0f1
--- /dev/null
+++ b/openedx/features/course_experience/api/v1/utils.py
@@ -0,0 +1,115 @@
+
+"""
+Course Experience API utilities.
+"""
+import logging
+from eventtracking import tracker
+
+from lms.djangoapps.courseware.access import has_access
+from lms.djangoapps.courseware.masquerade import is_masquerading, setup_masquerade
+from lms.djangoapps.course_api.api import course_detail
+from openedx.core.djangoapps.schedules.utils import reset_self_paced_schedule
+from openedx.features.course_experience.utils import dates_banner_should_display
+
+
+logger = logging.getLogger(__name__)
+
+
+def reset_course_deadlines_for_user(user, course_key):
+ """
+ Core function to reset deadlines for a single course and user.
+
+ Args:
+ user: The user object
+ course_key: The course key
+
+ Returns:
+ bool: True if deadlines were reset, False if gated content prevents reset
+ """
+ # We ignore the missed_deadlines because this util is used in endpoint from the Learning MFE for
+ # learners who have remaining attempts on a problem and reset their due dates in order to
+ # submit additional attempts. This can apply for 'completed' (submitted) content that would
+ # not be marked as past_due
+ _missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, user)
+ if not missed_gated_content:
+ reset_self_paced_schedule(user, course_key)
+ return True
+ return False
+
+
+def reset_bulk_course_deadlines(request, course_keys, research_event_data={}): # lint-amnesty, pylint: disable=dangerous-default-value
+ """
+ Reset deadlines for multiple courses for the requesting user.
+
+ Args:
+ request (Request): The request object
+ course_keys (list): List of course keys
+ research_event_data (dict): Any data that should be included in the research tracking event
+
+ Returns:
+ tuple: (success_course_keys, failed_course_keys)
+ """
+ success_course_keys = []
+ failed_course_keys = []
+
+ for course_key in course_keys:
+ try:
+ course_masquerade, user = setup_masquerade(
+ request,
+ course_key,
+ has_access(request.user, 'staff', course_key)
+ )
+
+ if reset_course_deadlines_for_user(user, course_key):
+ success_course_keys.append(course_key)
+
+ course_overview = course_detail(request, user.username, course_key)
+
+ research_event_data.update({
+ 'courserun_key': str(course_key),
+ 'is_masquerading': is_masquerading(user, course_key, course_masquerade),
+ 'is_staff': has_access(user, 'staff', course_key).has_access,
+ 'org_key': course_overview.display_org_with_default,
+ 'user_id': user.id,
+ })
+ tracker.emit('edx.ui.lms.reset_deadlines.clicked', research_event_data)
+ else:
+ failed_course_keys.append(course_key)
+ except Exception: # pylint: disable=broad-exception-caught
+ logger.exception('Error occurred while trying to reset deadlines!')
+ failed_course_keys.append(course_key)
+
+ return success_course_keys, failed_course_keys
+
+
+def reset_deadlines_for_course(request, course_key, research_event_data={}): # lint-amnesty, pylint: disable=dangerous-default-value
+ """
+ Set the start_date of a schedule to today, which in turn will adjust due dates for
+ sequentials belonging to a self paced course
+
+ Args:
+ request (Request): The request object
+ course_key (str): The course key
+ research_event_data (dict): Any data that should be included in the research tracking event
+ Example: sending the location of where the reset deadlines banner (i.e. outline-tab)
+ """
+
+ course_masquerade, user = setup_masquerade(
+ request,
+ course_key,
+ has_access(request.user, 'staff', course_key)
+ )
+
+ if reset_course_deadlines_for_user(user, course_key):
+ course_overview = course_detail(request, user.username, course_key)
+ # For context here, research_event_data should already contain `location` indicating
+ # the page/location dates were reset from and could also contain `block_id` if reset
+ # within courseware.
+ research_event_data.update({
+ 'courserun_key': str(course_key),
+ 'is_masquerading': is_masquerading(user, course_key, course_masquerade),
+ 'is_staff': has_access(user, 'staff', course_key).has_access,
+ 'org_key': course_overview.display_org_with_default,
+ 'user_id': user.id,
+ })
+ tracker.emit('edx.ui.lms.reset_deadlines.clicked', research_event_data)
diff --git a/openedx/features/course_experience/api/v1/views.py b/openedx/features/course_experience/api/v1/views.py
index 16be4a4e0e..d822fdd65c 100644
--- a/openedx/features/course_experience/api/v1/views.py
+++ b/openedx/features/course_experience/api/v1/views.py
@@ -5,7 +5,6 @@ import logging
from django.utils.html import format_html
from django.utils.translation import gettext as _
-from eventtracking import tracker
from rest_framework.decorators import api_view, authentication_classes, permission_classes
from rest_framework.exceptions import APIException, ParseError
@@ -17,17 +16,14 @@ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthenticat
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from opaque_keys.edx.keys import CourseKey
-from lms.djangoapps.course_api.api import course_detail
+from common.djangoapps.student.models import CourseEnrollment
from lms.djangoapps.course_goals.models import UserActivity
-from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.courses import get_course_with_access
-from lms.djangoapps.courseware.masquerade import is_masquerading, setup_masquerade
-from openedx.core.djangoapps.schedules.utils import reset_self_paced_schedule
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from openedx.features.course_experience.api.v1.serializers import CourseDeadlinesMobileSerializer
from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url
-from openedx.features.course_experience.utils import dates_banner_should_display
+from openedx.features.course_experience.api.v1.utils import reset_deadlines_for_course, reset_bulk_course_deadlines
log = logging.getLogger(__name__)
@@ -65,32 +61,7 @@ def reset_course_deadlines(request):
try:
course_key = CourseKey.from_string(course_key)
- course_masquerade, user = setup_masquerade(
- request,
- course_key,
- has_access(request.user, 'staff', course_key)
- )
-
- # We ignore the missed_deadlines because this endpoint is used in the Learning MFE for
- # learners who have remaining attempts on a problem and reset their due dates in order to
- # submit additional attempts. This can apply for 'completed' (submitted) content that would
- # not be marked as past_due
- _missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, user)
- if not missed_gated_content:
- reset_self_paced_schedule(user, course_key)
-
- course_overview = course_detail(request, user.username, course_key)
- # For context here, research_event_data should already contain `location` indicating
- # the page/location dates were reset from and could also contain `block_id` if reset
- # within courseware.
- research_event_data.update({
- 'courserun_key': str(course_key),
- 'is_masquerading': is_masquerading(user, course_key, course_masquerade),
- 'is_staff': has_access(user, 'staff', course_key).has_access,
- 'org_key': course_overview.display_org_with_default,
- 'user_id': user.id,
- })
- tracker.emit('edx.ui.lms.reset_deadlines.clicked', research_event_data)
+ reset_deadlines_for_course(request, course_key, research_event_data)
body_link = get_learning_mfe_home_url(course_key=course_key, url_fragment='dates')
@@ -106,6 +77,44 @@ def reset_course_deadlines(request):
raise UnableToResetDeadlines from reset_deadlines_exception
+@api_view(["POST"])
+@authentication_classes(
+ (
+ JwtAuthentication,
+ BearerAuthenticationAllowInactiveUser,
+ SessionAuthenticationAllowInactiveUser,
+ )
+)
+@permission_classes((IsAuthenticated,))
+def reset_all_course_deadlines(request):
+ """
+ Set the start_date of a schedule to today for all enrolled courses
+
+ Request Parameters:
+ research_event_data: any data that should be included in the research tracking event
+ Example: sending the location of where the reset deadlines banner (i.e. outline-tab)
+
+ Returns:
+ success_course_keys: list of course keys for which deadlines were successfully reset
+ failed_course_keys: list of course keys for which deadlines could not be reset
+ """
+ research_event_data = request.data.get("research_event_data", {})
+ course_keys = list(
+ CourseEnrollment.enrollments_for_user(request.user).select_related("course").values_list("course_id", flat=True)
+ )
+
+ success_course_keys, failed_course_keys = reset_bulk_course_deadlines(
+ request, course_keys, research_event_data
+ )
+
+ return Response(
+ {
+ "success_course_keys": [str(key) for key in success_course_keys],
+ "failed_course_keys": [str(key) for key in failed_course_keys],
+ }
+ )
+
+
class CourseDeadlinesMobileView(RetrieveAPIView):
"""
**Use Cases**
diff --git a/openedx/features/enterprise_support/tests/test_logout.py b/openedx/features/enterprise_support/tests/test_logout.py
index c83b67c3a5..cbec233977 100644
--- a/openedx/features/enterprise_support/tests/test_logout.py
+++ b/openedx/features/enterprise_support/tests/test_logout.py
@@ -19,6 +19,7 @@ from openedx.features.enterprise_support.tests import (
factories
)
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseServiceMockMixin
+from common.test.utils import assert_dict_contains_subset
@ddt.ddt
@@ -60,7 +61,7 @@ class EnterpriseLogoutTests(EnterpriseServiceMockMixin, CacheIsolationTestCase,
expected = {
'enterprise_target': enterprise_target,
}
- self.assertDictContainsSubset(expected, response.context_data)
+ assert_dict_contains_subset(self, expected, response.context_data)
if enterprise_target:
self.assertContains(response, 'We are signing you in.')
diff --git a/openedx/features/offline_content/docs/001-mobile-offline-content-support.rst b/openedx/features/offline_content/docs/001-mobile-offline-content-support.rst
new file mode 100644
index 0000000000..29ecf7d01e
--- /dev/null
+++ b/openedx/features/offline_content/docs/001-mobile-offline-content-support.rst
@@ -0,0 +1,156 @@
+1. Offline content generation for mobile OeX app
+=============================================
+
+Status
+------
+
+Accepted
+
+Context
+-------
+
+The primary goal is to enable offline access to course content in the Open edX mobile application.
+This will allow users to download course materials when they have internet access and access them
+later without an internet connection, also it should support synchronization of the submitted results
+with backend service as connection become available again. This feature is crucial for learners
+in areas with unreliable internet connectivity or those who prefer to study on the go without using mobile data.
+It is possible to provide different kind of content using the Open edX platform, such as read-only materials,
+videos, and assessments. Therefore to provide the whole course experience in offline mode it's required to
+make all these types of content available offline. Of course it won't be feasible to recreate grading
+algorithms in mobile, so it's possible to save submission on the mobile app and execute synchronization
+of the user progres as not limited connectivity is back.
+
+From the product perspective the following Figma designs and product requirements should be considered:
+
+* `Download and Delete (Figma)`_
+* `Downloads (Figma)`_
+
+.. _Download and Delete (Figma): https://www.figma.com/design/iZ56YMjbRMShCCDxqrqRrR/Mobile-App-v2.4-%5BOpen-edX%5D?node-id=18472-187387&t=tMgymS6WIZZJbJHn-0
+.. _Downloads (Figma): https://www.figma.com/design/iZ56YMjbRMShCCDxqrqRrR/Mobile-App-v2.4-%5BOpen-edX%5D
+
+Decision
+--------
+
+The implementation of the offline content support require addition of the following features to the edx-platform:
+
+* It's necessary to generate an archive with all necessary HTML and assets for a student view of an xBlock, so it's possible to display an xBlock using mobile WebView.
+* Implement a new standard XBlock view called `offline_view` which would generate user-agnostic fragments suitable for offline use. This view will avoid any dependence on student-specific state, focusing solely on content and settings.
+* XBlock classes can opt into supporting `offline_view`. They can implement this view fully or partially. For example, a block that relies on user-specific randomization or interactive elements that require online connectivity would not be rendered offline.
+* The generated offline content should be provided to mobile device through mobile API.
+* To support CAPA problems and other kinds of assessments in offline mode it's necessary to create an additional
+ JavaScript layer that will allow communication with Mobile applications by sending JSON messages
+ using Android and IOS Bridge.
+
+
+Offline content generation
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Generating zip archive with xBlock data for HTML and CAPA problems
+When content is published in CMS and offline generation is enabled for the course or entire platform using waffle flags, the content generation task should be started for supported blocks.
+Every time block content republished ZIP archive with offline content should be regenerated.
+Supported XBlock class should implement `offline_view` method that will be used to generate the content.
+HTML should be processed, all related assets files, images and scripts should be included in the generated ZIP archive with offline content
+The Generation process should work with local media storage as well as s3.
+If error retrieving block happened, the generation task will be scheduled for retry 2 more times, with progressive delay.
+
+ .. image:: _images/mobile_offline_content_generation.svg
+ :alt: Mobile Offline Content Generation Process Diagram
+
+
+Offline content deletion
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+When the course is published and some blocks are removed from the course, related ZIP archive should be deleted.
+When some blocks are removed from the course without publishing the course, the related ZIP archive shouldn't be deleted.
+
+
+Mobile API extension
+~~~~~~~~~~~~~~~~~~~~
+
+Extend the Course Home mobile API endpoint, and add a new version of the API (url /api/mobile/v4/course_info/blocks/)
+to return information about offline content available for download for supported blocks
+
+.. code-block:: json
+ {
+ ...
+ "offline_download": {
+ "file_url": "{file_url}" or null,
+ "last_modified": "{DT}" or null,
+ "file_size": ""
+ }
+ }
+
+
+JavaScript Bridge for interaction with mobile applications
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Implement JS Bridge JS script to intercept and send results to mobile device for supported CAPA problems.
+
+The JS bridge will intercept AJAX requests in the mobile application and store the responses locally. If the user submits the response offline he will be shown the message "Your response is accepted" and the Submit button will be disabled as the submission will be sent twice.
+When the internet connection is back the mobile client will submit the cached responses one by one through the regular xBlock handler endpoints.
+Data from submission should be submitted through bridge on iOS and Android devices.
+This script should expose markCompleted JS function so mobile can change state of the offline problem after the data was saved into internal database or on initialization of the problem.
+
+* **Implement of a mechanism for generating and storing on a server or external storage**: The course content should be pre-generated and saved to the storage for later download.
+ * **Render block fragment**: Implement a new standard XBlock view called `offline_view` which would generate user-agnostic fragments suitable for offline use. This view will avoid any dependence on student-specific state, focusing solely on content and settings.
+ * **Replace static and media**: Save static and media assets files used in block to temporary directory and replace their static paths with local paths.
+ * **Archive and store content**: Archive the generated content and store it on the server or external storage.
+* **Mechanism for updating the generated data**: When updating course blocks (namely when publishing) the content that has been changed should be re-generated.
+ * **Track course publishing events on CMS side**: Add a new signal `course_cache_updated` to be called after the course structure cache update in `update_course_in_cache_v2`. Add a signal that listens to `course_cache_updated` and starts block generation.
+ * **Update archive**: Check generated archive creation date and update it if less than course publishing date.
+* **Implement a Mobile Local Storage Mechanism**: Use the device's local storage to save course content for offline access.
+ * **Extend blocks API**: Add links to download blocks content and where it is possible.
+* **Sync Mechanism**: Periodically synchronize local data with the server when the device is online.
+ * **Sync on app side**: On course outline screen, check if the course content is up to date and update it if necessary.
+ * **Sync user responses**: When the device is offline, save user responses locally and send them to the server when the device is online.
+* **Selective Download**: Allow users to choose specific content to download for offline use.
+* **Full Course Download**: Provide an option to download entire courses for offline access.
+
+Supported xBlocks in offline mode
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+It was decided to include a fraction of Open edX xBlocks to be supported.
+The following list of blocks is currently planned to be added to the support:
+
+* **Common problems**:
+ * **Checkboxes** - full support
+ * **Dropdown** - full support
+ * **Multiple Choice** - full support
+ * **Numerical Input** - full support
+ * **Text Input** - full support
+ * **Checkboxes with Hints and Feedback** - partial support without Hints and Feedback
+ * **Dropdown with Hints and Feedback** - partial support without Hints and Feedback
+ * **Multiple Choice with Hints and Feedback** - partial support without Hints and Feedback
+ * **Numerical Input with Hints and Feedback** - partially supported without Hints and Feedback
+ * **Text Input with Hints and Feedback** - partially supported without Hints and Feedback
+ * **Blank Advanced Problems** - partially supported, without loncapa/python problems or multi-part problems
+* **Text**:
+ * **Text** - full support
+ * **IFrame Tool** - full support
+ * **Raw HTML** - full support
+ * **Zooming Image Tool** - full support
+* **Video** - already supported
+
+
+Consequences
+------------
+
+* Enhanced learner experience with flexible access to course materials.
+* Increased accessibility for learners in regions with poor internet connectivity.
+* Improved engagement and completion rates due to uninterrupted access to content.
+* Simplified Maintenance by using a unified rendering view (`offline_view`), the complexity of maintaining separate renderers for online and offline content is significantly reduced.
+* The proposed approach not only caters to the current needs of mobile users but also sets a foundation for expanding offline access to other platforms and uses.
+* Potential increase in app size due to locally stored content.
+* Increased complexity in managing content synchronization and updates.
+* Need for continuous monitoring and updates to handle new content types and formats.
+
+Rejected Solutions
+------------------
+
+* **Store common .js and .css files of blocks in a separate folder:**
+ * This solution was rejected because it is unclear how to track potential changes to these files and re-generate the content of the blocks.
+
+* **Generate content on the fly when the user requests it:**
+ * This solution was rejected because it would require a significant amount of processing power and time to generate content for each block when requested.
+
+* **Separate Offline Renderer**:
+ * The initial proposal of creating a separate renderer for offline content was rejected due to the increased complexity and potential for inconsistent behavior between online and offline content.
diff --git a/openedx/features/offline_content/docs/002-mobile-offline-content-support.rst b/openedx/features/offline_content/docs/002-mobile-offline-content-support.rst
new file mode 100644
index 0000000000..7d1e91e7e8
--- /dev/null
+++ b/openedx/features/offline_content/docs/002-mobile-offline-content-support.rst
@@ -0,0 +1,55 @@
+2. Offline Mode enhancements
+=========================
+
+Status
+------
+
+Proposed
+
+Context
+-------
+
+`offline_view` generalized and can be used for Non-mobile offline mode, Anonymous access or Regular student access.
+Static files like JavaScript and CSS will be de-duplicated based on their content hash.
+
+Decisions
+--------
+
+1. Efficient resource management
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ - Shared resources like JS and CSS files will be de-duplicated based on their content hash, to prevent duplication for every block.
+ - All shared content should be stored in the separate ZIP archive.
+ - This archive will be regenerated 1 time and contains all JS and CSS files related to default Xblocks.
+ - Xblock specific resources will still be stored in the block ZIP archive.
+ - This will ensure that the same resource is not duplicated across different blocks, reducing storage and bandwidth usage.
+
+
+2. Anonymous access
+~~~~~~~~~~~~~~~~~~~
+
+ - Re-implement `public_view` on top of `offline_view`. If it is possible to get pre-rendered block without knowing user state, then it is possible to serve that pre-renderable view as the public experience for logged-out users.
+ - This will allow broader access to educational content without the need for user authentication, potentially increasing user engagement and content reach.
+
+
+3. Non-mobile offline mode
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ - The `offline_view` will be generalized to support non-mobile offline mode.
+ - This mode will enable users on desktop and other non-mobile platforms to download and access course content without an active internet connection, providing greater flexibility in how content is accessed.
+
+
+4. Regular student access
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ - `student_view` will be implemented on top of `offline_view` wherever it is supported.
+ - For XBlocks compatible with this architecture, offline-ready content will be served by default, and dynamic online features will be engaged only when a user has a reliable connection.
+ - This setting is intended to improve the learning process by providing constant access to content when the Internet connection is unstable.
+
+
+Consequences
+------------
+
+* **Resource Efficiency**: The avoidance of duplicating static resources for each block enhances the efficient use of storage and bandwidth.
+* **Enhanced Flexibility**: The system can skip rendering blocks that require student-specific interactions, ensuring reliability and reducing the potential for behavior discrepancies between online and offline modes.
+* **Broader Accessibility**: The ability to serve pre-rendered views to anonymous users increases the reach of educational content, making it more accessible to a wider audience.
diff --git a/openedx/features/offline_content/docs/_images/mobile_offline_content_generation.svg b/openedx/features/offline_content/docs/_images/mobile_offline_content_generation.svg
new file mode 100644
index 0000000000..ca07617294
--- /dev/null
+++ b/openedx/features/offline_content/docs/_images/mobile_offline_content_generation.svg
@@ -0,0 +1,4 @@
+
+
+
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 0da387590a..1c0c3f3978 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -89,7 +89,6 @@
"karma-jasmine-html-reporter": "0.2.2",
"karma-junit-reporter": "2.0.1",
"karma-requirejs": "1.1.0",
- "karma-selenium-webdriver-launcher": "github:openedx/karma-selenium-webdriver-launcher#0.0.4-openedx.0",
"karma-sourcemap-loader": "0.4.0",
"karma-spec-reporter": "0.0.20",
"karma-webpack": "^5.0.1",
@@ -12925,19 +12924,6 @@
"requirejs": "^2.1.0"
}
},
- "node_modules/karma-selenium-webdriver-launcher": {
- "version": "0.0.4-openedx.0",
- "resolved": "git+ssh://git@github.com/openedx/karma-selenium-webdriver-launcher.git#79cfdc5037eb8585dd3e584875e4343febb6d61f",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "q": "~0.9.6"
- },
- "peerDependencies": {
- "karma": ">=0.9",
- "selenium-webdriver": ">=2.44.0"
- }
- },
"node_modules/karma-sourcemap-loader": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/karma-sourcemap-loader/-/karma-sourcemap-loader-0.4.0.tgz",
diff --git a/package.json b/package.json
index a8b762450e..0899b244bb 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"watch-sass": "scripts/watch_sass.sh",
"test": "npm run test-jest && npm run test-karma",
"test-jest": "jest",
- "test-karma": "npm run test-karma-vanilla && npm run test-karma-require && echo 'WARNING: Skipped broken webpack tests. For details, see: https://github.com/openedx/edx-platform/issues/35956'",
+ "test-karma": "npm run test-karma-vanilla && npm run test-karma-require && npm run test-xmodule-webpack && echo 'WARNING: Skipped broken lms-webpack and cms-webpack tests. For details, see: https://github.com/openedx/edx-platform/issues/35956'",
"test-karma-vanilla": "npm run test-cms-vanilla && npm run test-xmodule-vanilla && npm run test-common-vanilla",
"test-karma-require": "npm run test-cms-require && npm run test-common-require",
"test-karma-webpack": "npm run test-cms-webpack && npm run test-lms-webpack && npm run test-xmodule-webpack",
@@ -114,7 +114,6 @@
"karma-jasmine-html-reporter": "0.2.2",
"karma-junit-reporter": "2.0.1",
"karma-requirejs": "1.1.0",
- "karma-selenium-webdriver-launcher": "github:openedx/karma-selenium-webdriver-launcher#0.0.4-openedx.0",
"karma-sourcemap-loader": "0.4.0",
"karma-spec-reporter": "0.0.20",
"karma-webpack": "^5.0.1",
diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt
index af5c9e04c6..368f8fa811 100644
--- a/requirements/common_constraints.txt
+++ b/requirements/common_constraints.txt
@@ -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.
-
diff --git a/requirements/constraints.txt b/requirements/constraints.txt
index 3c36ffafcd..3525cdab24 100644
--- a/requirements/constraints.txt
+++ b/requirements/constraints.txt
@@ -13,16 +13,16 @@
# This file contains all common constraints for edx-repos
-c common_constraints.txt
+# Date: 2025-10-07
+# Stay on LTS version, remove once this is added to common constraint
+Django<6.0
+
# Date: 2020-02-26
# As it is not clarified what exact breaking changes will be introduced as per
# the next major release, ensure the installed version is within boundaries.
# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35280
celery>=5.2.2,<6.0.0
-# Date: 2024-02-02
-# Stay on LTS version, remove once this is added to common constraint
-Django<5.0
-
# Date: 2020-02-10
# django-oauth-toolkit version >=2.0.0 has breaking changes. More details
# mentioned on this issue https://github.com/openedx/edx-platform/issues/32884
@@ -42,7 +42,7 @@ django-stubs<6
# The team that owns this package will manually bump this package rather than having it pulled in automatically.
# This is to allow them to better control its deployment and to do it in a process that works better
# for them.
-edx-enterprise==6.4.4
+edx-enterprise==6.5.0
# Date: 2023-07-26
# Our legacy Sass code is incompatible with anything except this ancient libsass version.
@@ -125,3 +125,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
diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt
index ec9a8ff522..a2013ea748 100644
--- a/requirements/edx-sandbox/base.txt
+++ b/requirements/edx-sandbox/base.txt
@@ -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
diff --git a/requirements/edx/assets.txt b/requirements/edx/assets.txt
index bb6693f4dc..f66289e09b 100644
--- a/requirements/edx/assets.txt
+++ b/requirements/edx/assets.txt
@@ -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
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 59dfe5c7ec..6e6809d202 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -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
@@ -167,7 +167,7 @@ defusedxml==0.7.1
# ora2
# python3-openid
# social-auth-core
-django==4.2.25
+django==5.2.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/kernel.in
@@ -258,7 +258,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
@@ -277,7 +277,7 @@ django-fernet-fields-v2==0.9
# via
# edx-enterprise
# enterprise-integrated-channels
-django-filter==25.1
+django-filter==25.2
# via
# -r requirements/edx/kernel.in
# edx-enterprise
@@ -316,7 +316,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
@@ -404,7 +404,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
@@ -414,7 +414,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
@@ -441,7 +441,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
@@ -473,7 +473,7 @@ edx-drf-extensions==10.6.0
# edxval
# enterprise-integrated-channels
# openedx-learning
-edx-enterprise==6.4.4
+edx-enterprise==6.5.0
# via
# -c requirements/constraints.txt
# -r requirements/edx/kernel.in
@@ -528,7 +528,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
@@ -553,7 +553,7 @@ edx-when==3.0.0
# via
# -r requirements/edx/kernel.in
# edx-proctoring
-edxval==3.0.0
+edxval==3.1.0
# via -r requirements/edx/kernel.in
elasticsearch==7.9.1
# via
@@ -565,7 +565,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
@@ -579,7 +579,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
@@ -597,13 +597,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
@@ -627,11 +627,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
@@ -733,7 +733,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
@@ -750,7 +750,7 @@ markdown==3.9
# openedx-django-wiki
# staff-graded-xblock
# xblock-poll
-markupsafe==3.0.2
+markupsafe==3.0.3
# via
# chem
# jinja2
@@ -773,7 +773,7 @@ mpmath==1.3.0
# via sympy
msgpack==1.1.1
# via cachecontrol
-multidict==6.6.4
+multidict==6.7.0
# via
# aiohttp
# yarl
@@ -781,11 +781,11 @@ mysqlclient==2.2.7
# via
# -r requirements/edx/kernel.in
# openedx-forum
-nh3==0.3.0
+nh3==0.3.1
# 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
@@ -885,7 +885,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
@@ -900,7 +900,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
@@ -920,7 +920,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
@@ -957,9 +957,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
@@ -1011,7 +1011,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
@@ -1033,7 +1033,7 @@ referencing==0.36.2
# via
# jsonschema
# jsonschema-specifications
-regex==2025.9.1
+regex==2025.9.18
# via nltk
requests==2.32.5
# via
@@ -1085,9 +1085,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
@@ -1119,7 +1119,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
@@ -1178,6 +1178,7 @@ typing-extensions==4.15.0
# beautifulsoup4
# django-countries
# edx-opaque-keys
+ # grpcio
# jwcrypto
# pydantic
# pydantic-core
@@ -1186,7 +1187,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
@@ -1208,8 +1209,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
@@ -1219,7 +1218,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
@@ -1276,7 +1275,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
diff --git a/requirements/edx/coverage.txt b/requirements/edx/coverage.txt
index 57a6416926..010306d68c 100644
--- a/requirements/edx/coverage.txt
+++ b/requirements/edx/coverage.txt
@@ -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
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index 0bebe72b14..89c2d3de59 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -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
@@ -331,7 +331,7 @@ distlib==0.4.0
# via
# -r requirements/edx/testing.txt
# virtualenv
-django==4.2.25
+django==5.2.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/doc.txt
@@ -440,7 +440,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
@@ -469,7 +469,7 @@ django-fernet-fields-v2==0.9
# -r requirements/edx/testing.txt
# edx-enterprise
# enterprise-integrated-channels
-django-filter==25.1
+django-filter==25.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -520,7 +520,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
@@ -582,12 +582,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
@@ -628,7 +628,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
@@ -659,7 +659,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
@@ -674,7 +674,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
@@ -713,7 +713,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
@@ -747,7 +747,7 @@ edx-drf-extensions==10.6.0
# edxval
# enterprise-integrated-channels
# openedx-learning
-edx-enterprise==6.4.4
+edx-enterprise==6.5.0
# via
# -c requirements/constraints.txt
# -r requirements/edx/doc.txt
@@ -825,7 +825,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
@@ -855,7 +855,7 @@ edx-when==3.0.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-proctoring
-edxval==3.0.0
+edxval==3.1.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -876,7 +876,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
@@ -897,7 +897,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
@@ -920,7 +920,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
@@ -952,7 +952,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
@@ -960,7 +960,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
@@ -1005,13 +1005,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
@@ -1110,7 +1110,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
@@ -1213,7 +1213,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
@@ -1237,7 +1237,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
@@ -1290,13 +1290,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
@@ -1307,12 +1307,12 @@ mysqlclient==2.2.7
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# openedx-forum
-nh3==0.3.0
+nh3==0.3.1
# via
# -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
@@ -1424,7 +1424,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
@@ -1466,7 +1468,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
@@ -1493,7 +1495,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
@@ -1514,7 +1516,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
@@ -1554,7 +1556,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
@@ -1597,7 +1599,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
@@ -1647,12 +1649,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
@@ -1767,7 +1769,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
@@ -1799,7 +1801,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
@@ -1882,11 +1884,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
@@ -1939,7 +1941,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
@@ -1983,7 +1985,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
@@ -2025,7 +2027,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
@@ -2037,7 +2039,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
@@ -2082,7 +2084,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
@@ -2110,6 +2112,7 @@ typing-extensions==4.15.0
# edx-opaque-keys
# fastapi
# grimp
+ # grpcio
# import-linter
# jwcrypto
# mypy
@@ -2122,7 +2125,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
@@ -2160,11 +2163,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
@@ -2193,7 +2192,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
@@ -2279,7 +2278,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
diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt
index c70c61054c..8d4ad9ecb2 100644
--- a/requirements/edx/doc.txt
+++ b/requirements/edx/doc.txt
@@ -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
@@ -225,7 +225,7 @@ defusedxml==0.7.1
# ora2
# python3-openid
# social-auth-core
-django==4.2.25
+django==5.2.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/base.txt
@@ -322,7 +322,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
@@ -342,7 +342,7 @@ django-fernet-fields-v2==0.9
# -r requirements/edx/base.txt
# edx-enterprise
# enterprise-integrated-channels
-django-filter==25.1
+django-filter==25.2
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -385,7 +385,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
@@ -487,7 +487,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
@@ -498,7 +498,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
@@ -525,7 +525,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
@@ -557,7 +557,7 @@ edx-drf-extensions==10.6.0
# edxval
# enterprise-integrated-channels
# openedx-learning
-edx-enterprise==6.4.4
+edx-enterprise==6.5.0
# via
# -c requirements/constraints.txt
# -r requirements/edx/base.txt
@@ -613,7 +613,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
@@ -639,7 +639,7 @@ edx-when==3.0.0
# via
# -r requirements/edx/base.txt
# edx-proctoring
-edxval==3.0.0
+edxval==3.1.0
# via -r requirements/edx/base.txt
elasticsearch==7.9.1
# via
@@ -654,7 +654,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
@@ -674,7 +674,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
@@ -697,14 +697,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
@@ -738,12 +738,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
@@ -886,7 +886,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
@@ -905,7 +905,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
@@ -941,7 +941,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
@@ -950,11 +950,11 @@ mysqlclient==2.2.7
# via
# -r requirements/edx/base.txt
# openedx-forum
-nh3==0.3.0
+nh3==0.3.1
# via
# -r requirements/edx/base.txt
# xblocks-contrib
-nltk==3.9.1
+nltk==3.9.2
# via
# -r requirements/edx/base.txt
# chem
@@ -1075,7 +1075,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
@@ -1093,7 +1093,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
@@ -1118,7 +1118,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
@@ -1170,11 +1170,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
@@ -1235,7 +1235,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
@@ -1260,7 +1260,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
@@ -1329,9 +1329,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
@@ -1370,7 +1370,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
@@ -1408,7 +1408,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
@@ -1434,7 +1434,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
@@ -1488,6 +1488,7 @@ typing-extensions==4.15.0
# beautifulsoup4
# django-countries
# edx-opaque-keys
+ # grpcio
# jwcrypto
# pydantic
# pydantic-core
@@ -1497,7 +1498,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
@@ -1524,8 +1525,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
@@ -1540,7 +1539,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
@@ -1604,7 +1603,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
diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in
index 043c8f4794..2b043f71dd 100644
--- a/requirements/edx/kernel.in
+++ b/requirements/edx/kernel.in
@@ -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?
diff --git a/requirements/edx/semgrep.txt b/requirements/edx/semgrep.txt
index aec4c59acb..6adeb975ef 100644
--- a/requirements/edx/semgrep.txt
+++ b/requirements/edx/semgrep.txt
@@ -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
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index fdcf37cd5e..d7eab0980b 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -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,13 +245,13 @@ 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
distlib==0.4.0
# via virtualenv
-django==4.2.25
+django==5.2.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/base.txt
@@ -348,7 +348,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
@@ -368,7 +368,7 @@ django-fernet-fields-v2==0.9
# -r requirements/edx/base.txt
# edx-enterprise
# enterprise-integrated-channels
-django-filter==25.1
+django-filter==25.2
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -411,7 +411,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
@@ -508,7 +508,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
@@ -519,7 +519,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
@@ -546,7 +546,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
@@ -578,7 +578,7 @@ edx-drf-extensions==10.6.0
# edxval
# enterprise-integrated-channels
# openedx-learning
-edx-enterprise==6.4.4
+edx-enterprise==6.5.0
# via
# -c requirements/constraints.txt
# -r requirements/edx/base.txt
@@ -636,7 +636,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
@@ -662,7 +662,7 @@ edx-when==3.0.0
# via
# -r requirements/edx/base.txt
# edx-proctoring
-edxval==3.0.0
+edxval==3.1.0
# via -r requirements/edx/base.txt
elasticsearch==7.9.1
# via
@@ -677,7 +677,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
@@ -691,7 +691,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
@@ -709,7 +709,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
@@ -728,14 +728,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
@@ -771,12 +771,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
@@ -847,7 +847,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
@@ -928,7 +928,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
@@ -947,7 +947,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
@@ -986,7 +986,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
@@ -995,11 +995,11 @@ mysqlclient==2.2.7
# via
# -r requirements/edx/base.txt
# openedx-forum
-nh3==0.3.0
+nh3==0.3.1
# via
# -r requirements/edx/base.txt
# xblocks-contrib
-nltk==3.9.1
+nltk==3.9.2
# via
# -r requirements/edx/base.txt
# chem
@@ -1080,7 +1080,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
@@ -1132,7 +1134,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
@@ -1150,7 +1152,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
@@ -1183,7 +1185,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
@@ -1213,7 +1215,7 @@ pylatexenc==2.10
# via
# -r requirements/edx/base.txt
# olxcleaner
-pylint==3.3.8
+pylint==3.3.9
# via
# edx-lint
# pylint-celery
@@ -1249,11 +1251,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
@@ -1346,7 +1348,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
@@ -1369,7 +1371,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
@@ -1436,9 +1438,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
@@ -1476,7 +1478,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
@@ -1508,7 +1510,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
@@ -1545,7 +1547,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
@@ -1562,6 +1564,7 @@ typing-extensions==4.15.0
# edx-opaque-keys
# fastapi
# grimp
+ # grpcio
# import-linter
# jwcrypto
# pydantic
@@ -1572,7 +1575,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
@@ -1602,9 +1605,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
@@ -1622,7 +1623,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
@@ -1686,7 +1687,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
diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt
index b19a4faaa0..e97cb1b3d3 100644
--- a/requirements/pip-tools.txt
+++ b/requirements/pip-tools.txt
@@ -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
diff --git a/scripts/structures_pruning/requirements/base.txt b/scripts/structures_pruning/requirements/base.txt
index a01b730c49..aa4a8c1576 100644
--- a/scripts/structures_pruning/requirements/base.txt
+++ b/scripts/structures_pruning/requirements/base.txt
@@ -4,7 +4,7 @@
#
# make upgrade
#
-click==8.2.1
+click==8.3.0
# via
# -r scripts/structures_pruning/requirements/base.in
# click-log
diff --git a/scripts/structures_pruning/requirements/testing.txt b/scripts/structures_pruning/requirements/testing.txt
index 83d3f5746e..1b387d33c4 100644
--- a/scripts/structures_pruning/requirements/testing.txt
+++ b/scripts/structures_pruning/requirements/testing.txt
@@ -4,7 +4,7 @@
#
# make upgrade
#
-click==8.2.1
+click==8.3.0
# via
# -r scripts/structures_pruning/requirements/base.txt
# click-log
diff --git a/scripts/user_retirement/requirements/base.txt b/scripts/user_retirement/requirements/base.txt
index b77e368467..0507348b22 100644
--- a/scripts/user_retirement/requirements/base.txt
+++ b/scripts/user_retirement/requirements/base.txt
@@ -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,13 +26,15 @@ 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
cryptography==45.0.7
- # via pyjwt
-django==4.2.25
+ # via
+ # -c requirements/constraints.txt
+ # pyjwt
+django==5.2.7
# via
# -c requirements/constraints.txt
# django-crum
@@ -42,15 +44,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
@@ -88,7 +90,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
@@ -104,7 +106,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
@@ -112,7 +114,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
@@ -134,7 +136,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
diff --git a/scripts/user_retirement/requirements/testing.txt b/scripts/user_retirement/requirements/testing.txt
index f0373c1817..01ba9d65a4 100644
--- a/scripts/user_retirement/requirements/testing.txt
+++ b/scripts/user_retirement/requirements/testing.txt
@@ -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
@@ -52,7 +52,7 @@ cryptography==45.0.7
# pyjwt
ddt==1.7.2
# via -r scripts/user_retirement/requirements/testing.in
-django==4.2.25
+django==5.2.7
# via
# -r scripts/user_retirement/requirements/base.txt
# django-crum
@@ -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
diff --git a/scripts/xblock/requirements.txt b/scripts/xblock/requirements.txt
index 52fb237cb8..23d2ac5b8e 100644
--- a/scripts/xblock/requirements.txt
+++ b/scripts/xblock/requirements.txt
@@ -4,7 +4,7 @@
#
# make upgrade
#
-certifi==2025.8.3
+certifi==2025.10.5
# via requests
charset-normalizer==3.4.3
# via requests
diff --git a/setup.py b/setup.py
index 5b9f020ac3..eeb7b79f53 100644
--- a/setup.py
+++ b/setup.py
@@ -139,7 +139,6 @@ setup(
],
"lms.djangoapp": [
"ace_common = openedx.core.djangoapps.ace_common.apps:AceCommonConfig",
- "announcements = openedx.features.announcements.apps:AnnouncementsConfig",
"content_libraries = openedx.core.djangoapps.content_libraries.apps:ContentLibrariesConfig",
"course_apps = openedx.core.djangoapps.course_apps.apps:CourseAppsConfig",
"course_live = openedx.core.djangoapps.course_live.apps:CourseLiveConfig",
@@ -158,7 +157,6 @@ setup(
"program_enrollments = lms.djangoapps.program_enrollments.apps:ProgramEnrollmentsConfig",
],
"cms.djangoapp": [
- "announcements = openedx.features.announcements.apps:AnnouncementsConfig",
"ace_common = openedx.core.djangoapps.ace_common.apps:AceCommonConfig",
"bookmarks = openedx.core.djangoapps.bookmarks.apps:BookmarksConfig",
"course_live = openedx.core.djangoapps.course_live.apps:CourseLiveConfig",
diff --git a/webpack.builtinblocks.config.js b/webpack.builtinblocks.config.js
index 1c5a9b1e0e..c0f2fdaeb4 100644
--- a/webpack.builtinblocks.config.js
+++ b/webpack.builtinblocks.config.js
@@ -79,14 +79,13 @@ module.exports = {
'./xmodule/js/src/xmodule.js',
'./xmodule/js/src/sequence/edit.js'
],
- VideoBlockDisplay: [
- './xmodule/js/src/xmodule.js',
- './xmodule/js/src/video/10_main.js'
- ],
VideoBlockEditor: [
'./xmodule/js/src/xmodule.js',
'./xmodule/js/src/tabs/tabs-aggregator.js'
],
+ VideoBlockDisplay: [
+ './xmodule/assets/video/public/js/10_main.js'
+ ],
WordCloudBlockDisplay: [
'./xmodule/js/src/xmodule.js',
'./xmodule/assets/word_cloud/src/js/word_cloud.js'
diff --git a/webpack.common.config.js b/webpack.common.config.js
index 36ac75708c..ac283c3d40 100644
--- a/webpack.common.config.js
+++ b/webpack.common.config.js
@@ -134,7 +134,6 @@ module.exports = Merge.merge({
// Features
Currency: './openedx/features/course_experience/static/course_experience/js/currency.js',
- AnnouncementsView: './openedx/features/announcements/static/announcements/jsx/Announcements.jsx',
CookiePolicyBanner: './common/static/js/src/CookiePolicyBanner.jsx',
// Common
@@ -505,15 +504,6 @@ module.exports = Merge.merge({
}
]
},
- {
- test: /xmodule\/js\/src\/video\/10_main.js/,
- use: [
- {
- loader: 'imports-loader',
- options: 'this=>window'
- }
- ]
- },
/*
* END BUILT-IN XBLOCK ASSETS WITH GLOBAL DEFINITIONS
***************************************************************************************************** */
@@ -680,9 +670,11 @@ module.exports = Merge.merge({
$: 'jQuery',
backbone: 'Backbone',
canvas: 'canvas',
+ fs: 'fs',
gettext: 'gettext',
jquery: 'jQuery',
logger: 'Logger',
+ path: 'path',
underscore: '_',
URI: 'URI',
XBlockToXModuleShim: 'XBlockToXModuleShim',
diff --git a/xmodule/assets/video/public/js/00_async_process.js b/xmodule/assets/video/public/js/00_async_process.js
new file mode 100644
index 0000000000..a909e8225a
--- /dev/null
+++ b/xmodule/assets/video/public/js/00_async_process.js
@@ -0,0 +1,52 @@
+'use strict';
+
+/**
+ * Provides convenient way to process big amount of data without UI blocking.
+ *
+ * @param {array} list Array to process.
+ * @param {function} process Calls this function on each item in the list.
+ * @return {array} Returns a Promise object to observe when all actions of a
+ * certain type bound to the collection, queued or not, have finished.
+ */
+let AsyncProcess = {
+ array: function(list, process) {
+ if (!_.isArray(list)) {
+ return $.Deferred().reject().promise();
+ }
+
+ if (!_.isFunction(process) || !list.length) {
+ return $.Deferred().resolve(list).promise();
+ }
+
+ let MAX_DELAY = 50, // maximum amount of time that js code should be allowed to run continuously
+ dfd = $.Deferred();
+ let result = [];
+ let index = 0;
+ let len = list.length;
+
+ let getCurrentTime = function() {
+ return (new Date()).getTime();
+ };
+
+ let handler = function() {
+ let start = getCurrentTime();
+
+ do {
+ result[index] = process(list[index], index);
+ index++;
+ } while (index < len && getCurrentTime() - start < MAX_DELAY);
+
+ if (index < len) {
+ setTimeout(handler, 25);
+ } else {
+ dfd.resolve(result);
+ }
+ };
+
+ setTimeout(handler, 25);
+
+ return dfd.promise();
+ }
+};
+
+export default AsyncProcess;
diff --git a/xmodule/assets/video/public/js/00_component.js b/xmodule/assets/video/public/js/00_component.js
new file mode 100644
index 0000000000..2ac183b198
--- /dev/null
+++ b/xmodule/assets/video/public/js/00_component.js
@@ -0,0 +1,81 @@
+'use strict';
+
+import _ from 'underscore';
+
+
+/**
+ * Creates a new object with the specified prototype object and properties.
+ * @param {Object} o The object which should be the prototype of the
+ * newly-created object.
+ * @private
+ * @throws {TypeError, Error}
+ * @return {Object}
+ */
+let inherit = Object.create || (function() {
+ let F = function() {};
+
+ return function(o) {
+ if (arguments.length > 1) {
+ throw Error('Second argument not supported');
+ }
+ if (_.isNull(o) || _.isUndefined(o)) {
+ throw Error('Cannot set a null [[Prototype]]');
+ }
+ if (!_.isObject(o)) {
+ throw TypeError('Argument must be an object');
+ }
+
+ F.prototype = o;
+
+ return new F();
+ };
+}());
+
+/**
+ * Component module.
+ * @exports video/00_component.js
+ * @constructor
+ * @return {jquery Promise}
+ */
+let Component = function() {
+ if ($.isFunction(this.initialize)) {
+ // eslint-disable-next-line prefer-spread
+ return this.initialize.apply(this, arguments);
+ }
+};
+
+/**
+ * Returns new constructor that inherits form the current constructor.
+ * @static
+ * @param {Object} protoProps The object containing which will be added to
+ * the prototype.
+ * @return {Object}
+ */
+Component.extend = function(protoProps, staticProps) {
+ let Parent = this;
+ let Child = function() {
+ if ($.isFunction(this.initialize)) {
+ // eslint-disable-next-line prefer-spread
+ return this.initialize.apply(this, arguments);
+ }
+ };
+
+ // Inherit methods and properties from the Parent prototype.
+ Child.prototype = inherit(Parent.prototype);
+ Child.constructor = Parent;
+ // Provide access to parent's methods and properties
+ Child.__super__ = Parent.prototype;
+
+ // Extends inherited methods and properties by methods/properties
+ // passed as argument.
+ if (protoProps) {
+ $.extend(Child.prototype, protoProps);
+ }
+
+ // Inherit static methods and properties
+ $.extend(Child, Parent, staticProps);
+
+ return Child;
+};
+
+export default Component;
diff --git a/xmodule/assets/video/public/js/00_i18n.js b/xmodule/assets/video/public/js/00_i18n.js
new file mode 100644
index 0000000000..1962ed4ee8
--- /dev/null
+++ b/xmodule/assets/video/public/js/00_i18n.js
@@ -0,0 +1,35 @@
+'use strict';
+
+/**
+ * i18n module.
+ * @exports video/00_i18n.js
+ * @return {object}
+ */
+
+let i18n = {
+ Play: gettext('Play'),
+ Pause: gettext('Pause'),
+ Mute: gettext('Mute'),
+ Unmute: gettext('Unmute'),
+ 'Exit full browser': gettext('Exit full browser'),
+ 'Fill browser': gettext('Fill browser'),
+ Speed: gettext('Speed'),
+ 'Auto-advance': gettext('Auto-advance'),
+ Volume: gettext('Volume'),
+ // Translators: Volume level equals 0%.
+ Muted: gettext('Muted'),
+ // Translators: Volume level in range ]0,20]%
+ 'Very low': gettext('Very low'),
+ // Translators: Volume level in range ]20,40]%
+ Low: gettext('Low'),
+ // Translators: Volume level in range ]40,60]%
+ Average: gettext('Average'),
+ // Translators: Volume level in range ]60,80]%
+ Loud: gettext('Loud'),
+ // Translators: Volume level in range ]80,99]%
+ 'Very loud': gettext('Very loud'),
+ // Translators: Volume level equals 100%.
+ Maximum: gettext('Maximum')
+};
+
+export default i18n;
diff --git a/xmodule/assets/video/public/js/00_iterator.js b/xmodule/assets/video/public/js/00_iterator.js
new file mode 100644
index 0000000000..5b597f200e
--- /dev/null
+++ b/xmodule/assets/video/public/js/00_iterator.js
@@ -0,0 +1,83 @@
+'use strict';
+
+/**
+ * Provides convenient way to work with iterable data.
+ * @exports video/00_iterator.js
+ * @constructor
+ * @param {array} list Array to be iterated.
+ */
+let Iterator = function(list) {
+ this.list = list;
+ this.index = 0;
+ this.size = this.list.length;
+ this.lastIndex = this.list.length - 1;
+};
+
+Iterator.prototype = {
+
+ /**
+ * Checks validity of provided index for the iterator.
+ * @access protected
+ * @param {numebr} index
+ * @return {boolean}
+ */
+ _isValid: function(index) {
+ return _.isNumber(index) && index < this.size && index >= 0;
+ },
+
+ /**
+ * Returns next element.
+ * @param {number} [index] Updates current position.
+ * @return {any}
+ */
+ next: function(index) {
+ if (!(this._isValid(index))) {
+ index = this.index;
+ }
+
+ this.index = (index >= this.lastIndex) ? 0 : index + 1;
+
+ return this.list[this.index];
+ },
+
+ /**
+ * Returns previous element.
+ * @param {number} [index] Updates current position.
+ * @return {any}
+ */
+ prev: function(index) {
+ if (!(this._isValid(index))) {
+ index = this.index;
+ }
+
+ this.index = (index < 1) ? this.lastIndex : index - 1;
+
+ return this.list[this.index];
+ },
+
+ /**
+ * Returns last element in the list.
+ * @return {any}
+ */
+ last: function() {
+ return this.list[this.lastIndex];
+ },
+
+ /**
+ * Returns first element in the list.
+ * @return {any}
+ */
+ first: function() {
+ return this.list[0];
+ },
+
+ /**
+ * Returns `true` if current position is last for the iterator.
+ * @return {boolean}
+ */
+ isEnd: function() {
+ return this.index === this.lastIndex;
+ }
+};
+
+export default Iterator;
diff --git a/xmodule/assets/video/public/js/00_resizer.js b/xmodule/assets/video/public/js/00_resizer.js
new file mode 100644
index 0000000000..d892ec4d18
--- /dev/null
+++ b/xmodule/assets/video/public/js/00_resizer.js
@@ -0,0 +1,236 @@
+'use strict';
+
+import _ from 'underscore';
+
+
+let Resizer = function(params) {
+ let defaults = {
+ container: window,
+ element: null,
+ containerRatio: null,
+ elementRatio: null
+ },
+ callbacksList = [],
+ delta = {
+ height: 0,
+ width: 0
+ },
+ module = {};
+ let mode = null,
+ config;
+
+ // eslint-disable-next-line no-shadow
+ let initialize = function(params) {
+ if (!config) {
+ config = defaults;
+ }
+
+ config = $.extend(true, {}, config, params);
+
+ if (!config.element) {
+ console.log(
+ 'Required parameter `element` is not passed.'
+ );
+ }
+
+ return module;
+ };
+
+ let getData = function() {
+ let $container = $(config.container),
+ containerWidth = $container.width() + delta.width,
+ containerHeight = $container.height() + delta.height;
+ let containerRatio = config.containerRatio;
+
+ let $element = $(config.element);
+ let elementRatio = config.elementRatio;
+
+ if (!containerRatio) {
+ containerRatio = containerWidth / containerHeight;
+ }
+
+ if (!elementRatio) {
+ elementRatio = $element.width() / $element.height();
+ }
+
+ return {
+ containerWidth: containerWidth,
+ containerHeight: containerHeight,
+ containerRatio: containerRatio,
+ element: $element,
+ elementRatio: elementRatio
+ };
+ };
+
+ let align = function() {
+ let data = getData();
+
+ switch (mode) {
+ case 'height':
+ alignByHeightOnly();
+ break;
+
+ case 'width':
+ alignByWidthOnly();
+ break;
+
+ default:
+ if (data.containerRatio >= data.elementRatio) {
+ alignByHeightOnly();
+ } else {
+ alignByWidthOnly();
+ }
+ break;
+ }
+
+ fireCallbacks();
+
+ return module;
+ };
+
+ let alignByWidthOnly = function() {
+ let data = getData(),
+ height = data.containerWidth / data.elementRatio;
+
+ data.element.css({
+ height: height,
+ width: data.containerWidth,
+ top: 0.5 * (data.containerHeight - height),
+ left: 0
+ });
+
+ return module;
+ };
+
+ let alignByHeightOnly = function() {
+ let data = getData(),
+ width = data.containerHeight * data.elementRatio;
+
+ data.element.css({
+ height: data.containerHeight,
+ width: data.containerHeight * data.elementRatio,
+ top: 0,
+ left: 0.5 * (data.containerWidth - width)
+ });
+
+ return module;
+ };
+
+ let setMode = function(param) {
+ if (_.isString(param)) {
+ mode = param;
+ align();
+ }
+
+ return module;
+ };
+
+ let setElement = function(element) {
+ config.element = element;
+
+ return module;
+ };
+
+ let addCallback = function(func) {
+ if ($.isFunction(func)) {
+ callbacksList.push(func);
+ } else {
+ console.error('[Video info]: TypeError: Argument is not a function.');
+ }
+
+ return module;
+ };
+
+ let addOnceCallback = function(func) {
+ if ($.isFunction(func)) {
+ let decorator = function() {
+ func();
+ removeCallback(func);
+ };
+
+ addCallback(decorator);
+ } else {
+ console.error('TypeError: Argument is not a function.');
+ }
+
+ return module;
+ };
+
+ let fireCallbacks = function() {
+ $.each(callbacksList, function(index, callback) {
+ callback();
+ });
+ };
+
+ let removeCallbacks = function() {
+ callbacksList.length = 0;
+
+ return module;
+ };
+
+ let removeCallback = function(func) {
+ let index = $.inArray(func, callbacksList);
+
+ if (index !== -1) {
+ return callbacksList.splice(index, 1);
+ }
+ };
+
+ let resetDelta = function() {
+ // eslint-disable-next-line no-multi-assign
+ delta.height = delta.width = 0;
+
+ return module;
+ };
+
+ let addDelta = function(value, side) {
+ if (_.isNumber(value) && _.isNumber(delta[side])) {
+ delta[side] += value;
+ }
+
+ return module;
+ };
+
+ let substractDelta = function(value, side) {
+ if (_.isNumber(value) && _.isNumber(delta[side])) {
+ delta[side] -= value;
+ }
+
+ return module;
+ };
+
+ let destroy = function() {
+ let data = getData();
+ data.element.css({
+ height: '', width: '', top: '', left: ''
+ });
+ removeCallbacks();
+ resetDelta();
+ mode = null;
+ };
+
+ initialize.apply(module, arguments);
+
+ return $.extend(true, module, {
+ align: align,
+ alignByWidthOnly: alignByWidthOnly,
+ alignByHeightOnly: alignByHeightOnly,
+ destroy: destroy,
+ setParams: initialize,
+ setMode: setMode,
+ setElement: setElement,
+ callbacks: {
+ add: addCallback,
+ once: addOnceCallback,
+ remove: removeCallback,
+ removeAll: removeCallbacks
+ },
+ delta: {
+ add: addDelta,
+ substract: substractDelta,
+ reset: resetDelta
+ }
+ });
+};
+
+export default Resizer;
diff --git a/xmodule/assets/video/public/js/00_sjson.js b/xmodule/assets/video/public/js/00_sjson.js
new file mode 100644
index 0000000000..99d870ff84
--- /dev/null
+++ b/xmodule/assets/video/public/js/00_sjson.js
@@ -0,0 +1,108 @@
+'use strict';
+
+let Sjson = function(data) {
+ let sjson = {
+ start: data.start.concat(),
+ text: data.text.concat()
+ },
+ module = {};
+
+ let getter = function(propertyName) {
+ return function() {
+ return sjson[propertyName];
+ };
+ };
+
+ let getStartTimes = getter('start');
+
+ let getCaptions = getter('text');
+
+ let size = function() {
+ return sjson.text.length;
+ };
+
+ function search(time, startTime, endTime) {
+ let start = getStartTimes(),
+ max = size() - 1,
+ min = 0,
+ results,
+ index;
+
+ // if we specify a start and end time to search,
+ // search the filtered list of captions in between
+ // the start / end times.
+ // Else, search the unfiltered list.
+ if (typeof startTime !== 'undefined'
+ && typeof endTime !== 'undefined') {
+ results = filter(startTime, endTime);
+ start = results.start;
+ max = results.captions.length - 1;
+ } else {
+ start = getStartTimes();
+ }
+ while (min < max) {
+ index = Math.ceil((max + min) / 2);
+
+ if (time < start[index]) {
+ max = index - 1;
+ }
+
+ if (time >= start[index]) {
+ min = index;
+ }
+ }
+
+ return min;
+ }
+
+ function filter(start, end) {
+ /* filters captions that occur between inputs
+ * `start` and `end`. Start and end should
+ * be Numbers (doubles) corresponding to the
+ * number of seconds elapsed since the beginning
+ * of the video.
+ *
+ * Returns an object with properties
+ * "start" and "captions" representing
+ * parallel arrays of start times and
+ * their corresponding captions.
+ */
+ let filteredTimes = [];
+ let filteredCaptions = [];
+ let startTimes = getStartTimes();
+ let captions = getCaptions();
+
+ if (startTimes.length !== captions.length) {
+ console.warn('video caption and start time arrays do not match in length');
+ }
+
+ // if end is null, then it's been set to
+ // some erroneous value, so filter using the
+ // entire array as long as it's not empty
+ if (end === null && startTimes.length) {
+ end = startTimes[startTimes.length - 1];
+ }
+
+ _.filter(startTimes, function(currentStartTime, i) {
+ if (currentStartTime >= start && currentStartTime <= end) {
+ filteredTimes.push(currentStartTime);
+ filteredCaptions.push(captions[i]);
+ }
+ });
+
+ return {
+ start: filteredTimes,
+ captions: filteredCaptions
+ };
+ }
+
+ return {
+ getCaptions: getCaptions,
+ getStartTimes: getStartTimes,
+ getSize: size,
+ filter: filter,
+ search: search
+ };
+};
+
+export default Sjson;
diff --git a/xmodule/assets/video/public/js/00_video_storage.js b/xmodule/assets/video/public/js/00_video_storage.js
new file mode 100644
index 0000000000..f2293336fe
--- /dev/null
+++ b/xmodule/assets/video/public/js/00_video_storage.js
@@ -0,0 +1,96 @@
+'use strict';
+
+/**
+ * Provides convenient way to store key value pairs.
+ *
+ * @param {string} namespace Namespace that is used to store data.
+ * @return {object} VideoStorage API.
+ */
+let VideoStorage = function(namespace, id) {
+ /**
+ * Adds new value to the storage or rewrites existent.
+ *
+ * @param {string} name Identifier of the data.
+ * @param {any} value Data to store.
+ * @param {boolean} instanceSpecific Data with this flag will be added
+ * to instance specific storage.
+ */
+ let setItem = function(name, value, instanceSpecific) {
+ if (name) {
+ if (instanceSpecific) {
+ window[namespace][id][name] = value;
+ } else {
+ window[namespace][name] = value;
+ }
+ }
+ };
+
+ /**
+ * Returns the current value associated with the given name.
+ *
+ * @param {string} name Identifier of the data.
+ * @param {boolean} instanceSpecific Data with this flag will be added
+ * to instance specific storage.
+ * @return {any} The current value associated with the given name.
+ * If the given key does not exist in the list
+ * associated with the object then this method must return null.
+ */
+ let getItem = function(name, instanceSpecific) {
+ if (instanceSpecific) {
+ return window[namespace][id][name];
+ } else {
+ return window[namespace][name];
+ }
+ };
+
+ /**
+ * Removes the current value associated with the given name.
+ *
+ * @param {string} name Identifier of the data.
+ * @param {boolean} instanceSpecific Data with this flag will be added
+ * to instance specific storage.
+ */
+ let removeItem = function(name, instanceSpecific) {
+ if (instanceSpecific) {
+ delete window[namespace][id][name];
+ } else {
+ delete window[namespace][name];
+ }
+ };
+
+ /**
+ * Empties the storage.
+ *
+ */
+ let clear = function() {
+ window[namespace] = {};
+ window[namespace][id] = {};
+ };
+
+ /**
+ * Initializes the module: creates a storage with proper namespace.
+ *
+ * @private
+ */
+ (function initialize() {
+ if (!namespace) {
+ namespace = 'VideoStorage';
+ }
+ if (!id) {
+ // Generate random alpha-numeric string.
+ id = Math.random().toString(36).slice(2);
+ }
+
+ window[namespace] = window[namespace] || {};
+ window[namespace][id] = window[namespace][id] || {};
+ }());
+
+ return {
+ clear: clear,
+ getItem: getItem,
+ removeItem: removeItem,
+ setItem: setItem
+ };
+};
+
+export default VideoStorage;
diff --git a/xmodule/assets/video/public/js/01_initialize.js b/xmodule/assets/video/public/js/01_initialize.js
new file mode 100644
index 0000000000..85248b3f02
--- /dev/null
+++ b/xmodule/assets/video/public/js/01_initialize.js
@@ -0,0 +1,845 @@
+/* eslint-disable no-console, no-param-reassign */
+/**
+ * @file Initialize module works with the JSON config, and sets up various
+ * settings, parameters, variables. After all setup actions are performed, it
+ * invokes the video player to play the specified video. This module must be
+ * invoked first. It provides several functions which do not fit in with other
+ * modules.
+ *
+ * @external VideoPlayer
+ *
+ * @module Initialize
+ */
+
+import VideoPlayer from './03_video_player.js';
+import i18n from './00_i18n.js';
+import _ from 'underscore';
+import moment from 'moment';
+
+/**
+ * @function
+ *
+ * Initialize module exports this function.
+ *
+ * @param {object} state The object containg the state of the video player.
+ * All other modules, their parameters, public variables, etc. are
+ * available via this object.
+ * @param {DOM element} element Container of the entire Video DOM element.
+ */
+let Initialize = function(state, element) {
+ _makeFunctionsPublic(state);
+
+ state.initialize(element)
+ .done(function() {
+ if (state.isYoutubeType()) {
+ state.parseSpeed();
+ }
+ // On iPhones and iPods native controls are used.
+ if (/iP(hone|od)/i.test(state.isTouch[0])) {
+ _hideWaitPlaceholder(state);
+ state.el.trigger('initialize', arguments);
+
+ return false;
+ }
+
+ _initializeModules(state, i18n)
+ .done(function() {
+ // On iPad ready state occurs just after start playing.
+ // We hide controls before video starts playing.
+ if (/iPad|Android/i.test(state.isTouch[0])) {
+ state.el.on('play', _.once(function() {
+ state.trigger('videoControl.show', null);
+ }));
+ } else {
+ // On PC show controls immediately.
+ state.trigger('videoControl.show', null);
+ }
+
+ _hideWaitPlaceholder(state);
+ state.el.trigger('initialize', arguments);
+ });
+ });
+};
+
+/* eslint-disable no-use-before-define */
+let methodsDict = {
+ bindTo: bindTo,
+ fetchMetadata: fetchMetadata,
+ getCurrentLanguage: getCurrentLanguage,
+ getDuration: getDuration,
+ getPlayerMode: getPlayerMode,
+ getVideoMetadata: getVideoMetadata,
+ initialize: initialize,
+ isHtml5Mode: isHtml5Mode,
+ isFlashMode: isFlashMode,
+ isYoutubeType: isYoutubeType,
+ parseSpeed: parseSpeed,
+ parseYoutubeStreams: parseYoutubeStreams,
+ setPlayerMode: setPlayerMode,
+ setSpeed: setSpeed,
+ setAutoAdvance: setAutoAdvance,
+ speedToString: speedToString,
+ trigger: trigger,
+ youtubeId: youtubeId,
+ loadHtmlPlayer: loadHtmlPlayer,
+ loadYoutubePlayer: loadYoutubePlayer,
+ loadYouTubeIFrameAPI: loadYouTubeIFrameAPI
+};
+/* eslint-enable no-use-before-define */
+
+let _youtubeApiDeferred = null;
+let _oldOnYouTubeIframeAPIReady;
+
+Initialize.prototype = methodsDict;
+
+export default Initialize;
+
+// ***************************************************************
+// Private functions start here. Private functions start with underscore.
+// ***************************************************************
+
+/**
+ * @function _makeFunctionsPublic
+ *
+ * Functions which will be accessible via 'state' object. When called,
+ * these functions will get the 'state'
+ * object as a context.
+ *
+ * @param {object} state The object containg the state (properties,
+ * methods, modules) of the Video player.
+ */
+function _makeFunctionsPublic(state) {
+ bindTo(methodsDict, state, state);
+}
+
+// function _renderElements(state)
+//
+// Create any necessary DOM elements, attach them, and set their
+// initial configuration. Also make the created DOM elements available
+// via the 'state' object. Much easier to work this way - you don't
+// have to do repeated jQuery element selects.
+function _renderElements(state) {
+ // Launch embedding of actual video content, or set it up so that it
+ // will be done as soon as the appropriate video player (YouTube or
+ // stand-alone HTML5) is loaded, and can handle embedding.
+ //
+ // Note that the loading of stand alone HTML5 player API is handled by
+ // Require JS. At the time when we reach this code, the stand alone
+ // HTML5 player is already loaded, so no further testing in that case
+ // is required.
+ let video;
+ let onYTApiReady;
+ let setupOnYouTubeIframeAPIReady;
+
+ if (state.videoType === 'youtube') {
+ state.youtubeApiAvailable = false;
+
+ onYTApiReady = function() {
+ console.log('[Video info]: YouTube API is available and is loaded.');
+ if (state.htmlPlayerLoaded) { return; }
+
+ console.log('[Video info]: Starting YouTube player.');
+ video = VideoPlayer(state);
+
+ state.modules.push(video);
+ state.__dfd__.resolve();
+ state.youtubeApiAvailable = true;
+ };
+
+ if (window.YT) {
+ // If we have a Deferred object responsible for calling OnYouTubeIframeAPIReady
+ // callbacks, make sure that they have all been called by trying to resolve the
+ // Deferred object. Upon resolving, all the OnYouTubeIframeAPIReady will be
+ // called. If the object has been already resolved, the callbacks will not
+ // be called a second time.
+ if (_youtubeApiDeferred) {
+ _youtubeApiDeferred.resolve();
+ }
+
+ window.YT.ready(onYTApiReady);
+ } else {
+ // There is only one global variable window.onYouTubeIframeAPIReady which
+ // is supposed to be a function that will be called by the YouTube API
+ // when it finished initializing. This function will update this global function
+ // so that it resolves our Deferred object, which will call all of the
+ // OnYouTubeIframeAPIReady callbacks.
+ //
+ // If this global function is already defined, we store it first, and make
+ // sure that it gets executed when our Deferred object is resolved.
+ setupOnYouTubeIframeAPIReady = function() {
+ _oldOnYouTubeIframeAPIReady = window.onYouTubeIframeAPIReady || undefined;
+
+ window.onYouTubeIframeAPIReady = function() {
+ _youtubeApiDeferred.resolve();
+ };
+
+ window.onYouTubeIframeAPIReady.done = _youtubeApiDeferred.done;
+
+ if (_oldOnYouTubeIframeAPIReady) {
+ window.onYouTubeIframeAPIReady.done(_oldOnYouTubeIframeAPIReady);
+ }
+ };
+
+ // If a Deferred object hasn't been created yet, create one now. It will
+ // be responsible for calling OnYouTubeIframeAPIReady callbacks once the
+ // YouTube API loads. After creating the Deferred object, load the YouTube
+ // API.
+ if (!_youtubeApiDeferred) {
+ _youtubeApiDeferred = $.Deferred();
+ setupOnYouTubeIframeAPIReady();
+ } else if (!window.onYouTubeIframeAPIReady || !window.onYouTubeIframeAPIReady.done) {
+ // The Deferred object could have been already defined in a previous
+ // initialization of the video module. However, since then the global variable
+ // window.onYouTubeIframeAPIReady could have been overwritten. If so,
+ // we should set it up again.
+ setupOnYouTubeIframeAPIReady();
+ }
+
+ // Attach a callback to our Deferred object to be called once the
+ // YouTube API loads.
+ window.onYouTubeIframeAPIReady.done(function() {
+ window.YT.ready(onYTApiReady);
+ });
+ }
+ } else {
+ video = VideoPlayer(state);
+
+ state.modules.push(video);
+ state.__dfd__.resolve();
+ state.htmlPlayerLoaded = true;
+ }
+}
+
+function _waitForYoutubeApi(state) {
+ console.log('[Video info]: Starting to wait for YouTube API to load.');
+ window.setTimeout(function() {
+ // If YouTube API will load OK, it will run `onYouTubeIframeAPIReady`
+ // callback, which will set `state.youtubeApiAvailable` to `true`.
+ // If something goes wrong at this stage, `state.youtubeApiAvailable` is
+ // `false`.
+ if (!state.youtubeApiAvailable) {
+ console.log('[Video info]: YouTube API is not available.');
+ if (!state.htmlPlayerLoaded) {
+ state.loadHtmlPlayer();
+ }
+ }
+ state.el.trigger('youtube_availability', [state.youtubeApiAvailable]);
+ }, state.config.ytTestTimeout);
+}
+
+function loadYouTubeIFrameAPI(scriptTag) {
+ let firstScriptTag = document.getElementsByTagName('script')[0];
+ firstScriptTag.parentNode.insertBefore(scriptTag, firstScriptTag);
+}
+
+// function _parseYouTubeIDs(state)
+// The function parse YouTube stream ID's.
+// @return
+// false: We don't have YouTube video IDs to work with; most likely
+// we have HTML5 video sources.
+// true: Parsing of YouTube video IDs went OK, and we can proceed
+// onwards to play YouTube videos.
+function _parseYouTubeIDs(state) {
+ if (state.parseYoutubeStreams(state.config.streams)) {
+ state.videoType = 'youtube';
+
+ return true;
+ }
+
+ console.log(
+ '[Video info]: Youtube Video IDs are incorrect or absent.'
+ );
+
+ return false;
+}
+
+/**
+ * Extract HLS video URLs from available video URLs.
+ *
+ * @param {object} state The object contaning the state (properties, methods, modules) of the Video player.
+ * @returns Array of available HLS video source urls.
+ */
+function extractHLSVideoSources(state) {
+ return _.filter(state.config.sources, function(source) {
+ return /\.m3u8(\?.*)?$/.test(source);
+ });
+}
+
+// function _prepareHTML5Video(state)
+// The function prepare HTML5 video, parse HTML5
+// video sources etc.
+function _prepareHTML5Video(state) {
+ state.speeds = ['0.75', '1.0', '1.25', '1.50', '2.0'];
+ // If none of the supported video formats can be played and there is no
+ // short-hand video links, than hide the spinner and show error message.
+ if (!state.config.sources.length) {
+ _hideWaitPlaceholder(state);
+ state.el
+ .find('.video-player div')
+ .addClass('hidden');
+ state.el
+ .find('.video-player .video-error')
+ .removeClass('is-hidden');
+
+ return false;
+ }
+
+ state.videoType = 'html5';
+
+ if (!_.keys(state.config.transcriptLanguages).length) {
+ state.config.showCaptions = false;
+ }
+ state.setSpeed(state.speed);
+
+ return true;
+}
+
+function _hideWaitPlaceholder(state) {
+ state.el
+ .addClass('is-initialized')
+ .find('.spinner')
+ .attr({
+ 'aria-hidden': 'true',
+ tabindex: -1
+ });
+}
+
+function _setConfigurations(state) {
+ state.setPlayerMode(state.config.mode);
+ // Possible value are: 'visible', 'hiding', and 'invisible'.
+ state.controlState = 'visible';
+ state.controlHideTimeout = null;
+ state.captionState = 'invisible';
+ state.captionHideTimeout = null;
+ state.HLSVideoSources = extractHLSVideoSources(state);
+}
+
+// eslint-disable-next-line no-shadow
+function _initializeModules(state, i18n) {
+ let dfd = $.Deferred(),
+ modulesList = $.map(state.modules, function(module) {
+ let options = state.options[module.moduleName] || {};
+ if (_.isFunction(module)) {
+ return module(state, i18n, options);
+ } else if ($.isPlainObject(module)) {
+ return module;
+ }
+ });
+
+ $.when.apply(null, modulesList)
+ .done(dfd.resolve);
+
+ return dfd.promise();
+}
+
+function _getConfiguration(data, storage) {
+ let isBoolean = function(value) {
+ let regExp = /^true$/i;
+ return regExp.test(value.toString());
+ },
+ // List of keys that will be extracted form the configuration.
+ extractKeys = [],
+ // Compatibility keys used to change names of some parameters in
+ // the final configuration.
+ compatKeys = {
+ start: 'startTime',
+ end: 'endTime'
+ },
+ // Conversions used to pre-process some configuration data.
+ conversions = {
+ showCaptions: isBoolean,
+ autoplay: isBoolean,
+ autohideHtml5: isBoolean,
+ autoAdvance: function(value) {
+ let shouldAutoAdvance = storage.getItem('auto_advance');
+ if (_.isUndefined(shouldAutoAdvance)) {
+ return isBoolean(value) || false;
+ } else {
+ return shouldAutoAdvance;
+ }
+ },
+ savedVideoPosition: function(value) {
+ return storage.getItem('savedVideoPosition', true)
+ || Number(value)
+ || 0;
+ },
+ speed: function(value) {
+ return storage.getItem('speed', true) || value;
+ },
+ generalSpeed: function(value) {
+ return storage.getItem('general_speed')
+ || value
+ || '1.0';
+ },
+ transcriptLanguage: function(value) {
+ return storage.getItem('language')
+ || value
+ || 'en';
+ },
+ ytTestTimeout: function(value) {
+ value = parseInt(value, 10);
+
+ if (!isFinite(value)) {
+ value = 1500;
+ }
+
+ return value;
+ },
+ startTime: function(value) {
+ value = parseInt(value, 10);
+ if (!isFinite(value) || value < 0) {
+ return 0;
+ }
+
+ return value;
+ },
+ endTime: function(value) {
+ value = parseInt(value, 10);
+
+ if (!isFinite(value) || value === 0) {
+ return null;
+ }
+
+ return value;
+ }
+ },
+ config = {};
+
+ data = _.extend({
+ startTime: 0,
+ endTime: null,
+ sub: '',
+ streams: ''
+ }, data);
+
+ $.each(data, function(option, value) {
+ // Extract option that is in `extractKeys`.
+ if ($.inArray(option, extractKeys) !== -1) {
+ return;
+ }
+
+ // Change option name to key that is in `compatKeys`.
+ if (compatKeys[option]) {
+ option = compatKeys[option];
+ }
+
+ // Pre-process data.
+ if (conversions[option]) {
+ if (_.isFunction(conversions[option])) {
+ value = conversions[option].call(this, value);
+ } else {
+ throw new TypeError(option + ' is not a function.');
+ }
+ }
+ config[option] = value;
+ });
+
+ return config;
+}
+
+// ***************************************************************
+// Public functions start here.
+// These are available via the 'state' object. Their context ('this'
+// keyword) is the 'state' object. The magic private function that makes
+// them available and sets up their context is makeFunctionsPublic().
+// ***************************************************************
+
+// function bindTo(methodsDict, obj, context, rewrite)
+// Creates a new function with specific context and assigns it to the provided
+// object.
+// eslint-disable-next-line no-shadow
+function bindTo(methodsDict, obj, context, rewrite) {
+ $.each(methodsDict, function(name, method) {
+ if (_.isFunction(method)) {
+ if (_.isUndefined(rewrite)) {
+ rewrite = true;
+ }
+
+ if (_.isUndefined(obj[name]) || rewrite) {
+ obj[name] = _.bind(method, context);
+ }
+ }
+ });
+}
+
+function loadYoutubePlayer() {
+ if (this.htmlPlayerLoaded) { return; }
+
+ console.log(
+ '[Video info]: Fetch metadata for YouTube video.'
+ );
+
+ this.fetchMetadata();
+ this.parseSpeed();
+}
+
+function loadHtmlPlayer() {
+ // When the youtube link doesn't work for any reason
+ // (for example, firewall) any
+ // alternate sources should automatically play.
+ if (!_prepareHTML5Video(this)) {
+ console.log(
+ '[Video info]: Continue loading '
+ + 'YouTube video.'
+ );
+
+ // Non-YouTube sources were not found either.
+
+ this.el.find('.video-player div')
+ .removeClass('hidden');
+ this.el.find('.video-player .video-error')
+ .addClass('is-hidden');
+
+ // If in reality the timeout was to short, try to
+ // continue loading the YouTube video anyways.
+ this.loadYoutubePlayer();
+ } else {
+ console.log(
+ '[Video info]: Start HTML5 player.'
+ );
+
+ // In-browser HTML5 player does not support quality
+ // control.
+ this.el.find('.quality_control').hide();
+ _renderElements(this);
+ }
+}
+
+// function initialize(element)
+// The function set initial configuration and preparation.
+
+function initialize(element) {
+ let self = this,
+ el = this.el,
+ id = this.id,
+ container = el.find('.video-wrapper'),
+ __dfd__ = $.Deferred(),
+ isTouch = onTouchBasedDevice() || '';
+
+ if (isTouch) {
+ el.addClass('is-touch');
+ }
+
+ $.extend(this, {
+ __dfd__: __dfd__,
+ container: container,
+ isFullScreen: false,
+ isTouch: isTouch
+ });
+
+ console.log('[Video info]: Initializing video with id "%s".', id);
+
+ // We store all settings passed to us by the server in one place. These
+ // are "read only", so don't modify them. All variable content lives in
+ // 'state' object.
+ // jQuery .data() return object with keys in lower camelCase format.
+ this.config = $.extend({}, _getConfiguration(this.metadata, this.storage), {
+ element: element,
+ fadeOutTimeout: 1400,
+ captionsFreezeTime: 10000,
+ mode: $.cookie('edX_video_player_mode'),
+ // Available HD qualities will only be accessible once the video has
+ // been played once, via player.getAvailableQualityLevels.
+ availableHDQualities: []
+ });
+
+ if (this.config.endTime < this.config.startTime) {
+ this.config.endTime = null;
+ }
+
+ this.lang = this.config.transcriptLanguage;
+ this.speed = this.speedToString(
+ this.config.speed || this.config.generalSpeed
+ );
+ this.auto_advance = this.config.autoAdvance;
+ this.htmlPlayerLoaded = false;
+ this.duration = this.metadata.duration;
+
+ _setConfigurations(this);
+
+ // If `prioritizeHls` is set to true than `hls` is the primary playback
+ if (this.config.prioritizeHls || !(_parseYouTubeIDs(this))) {
+ // If we do not have YouTube ID's, try parsing HTML5 video sources.
+ if (!_prepareHTML5Video(this)) {
+ __dfd__.reject();
+ // Non-YouTube sources were not found either.
+ return __dfd__.promise();
+ }
+
+ console.log('[Video info]: Start player in HTML5 mode.');
+ _renderElements(this);
+ } else {
+ _renderElements(this);
+
+ _waitForYoutubeApi(this);
+
+ let scriptTag = document.createElement('script');
+
+ scriptTag.src = this.config.ytApiUrl;
+ scriptTag.async = true;
+
+ $(scriptTag).on('load', function() {
+ self.loadYoutubePlayer();
+ });
+ $(scriptTag).on('error', function() {
+ console.log(
+ '[Video info]: YouTube returned an error for '
+ + 'video with id "' + self.id + '".'
+ );
+ // If the video is already loaded in `_waitForYoutubeApi` by the
+ // time we get here, then we shouldn't load it again.
+ if (!self.htmlPlayerLoaded) {
+ self.loadHtmlPlayer();
+ }
+ });
+
+ window.Video.loadYouTubeIFrameAPI(scriptTag);
+ }
+ return __dfd__.promise();
+}
+
+// function parseYoutubeStreams(state, youtubeStreams)
+//
+// Take a string in the form:
+// "iCawTYPtehk:0.75,KgpclqP-LBA:1.0,9-2670d5nvU:1.5"
+// parse it, and make it available via the 'state' object. If we are
+// not given a string, or it's length is zero, then we return false.
+//
+// @return
+// false: We don't have YouTube video IDs to work with; most likely
+// we have HTML5 video sources.
+// true: Parsing of YouTube video IDs went OK, and we can proceed
+// onwards to play YouTube videos.
+function parseYoutubeStreams(youtubeStreams) {
+ if (_.isUndefined(youtubeStreams) || !youtubeStreams.length) {
+ return false;
+ }
+
+ this.videos = {};
+
+ _.each(youtubeStreams.split(/,/), function(video) {
+ let speed;
+ video = video.split(/:/);
+ speed = this.speedToString(video[0]);
+ this.videos[speed] = video[1];
+ }, this);
+
+ return _.isString(this.videos['1.0']);
+}
+
+// function fetchMetadata()
+//
+// When dealing with YouTube videos, we must fetch meta data that has
+// certain key facts not available while the video is loading. For
+// example the length of the video can be determined from the meta
+// data.
+function fetchMetadata() {
+ let self = this,
+ metadataXHRs = [];
+
+ this.metadata = {};
+
+ metadataXHRs = _.map(this.videos, function(url, speed) {
+ return self.getVideoMetadata(url, function(data) {
+ if (data.items.length > 0) {
+ let metaDataItem = data.items[0];
+ self.metadata[metaDataItem.id] = metaDataItem.contentDetails;
+ }
+ });
+ });
+
+ $.when.apply(this, metadataXHRs).done(function() {
+ self.el.trigger('metadata_received');
+
+ // Not only do we trigger the "metadata_received" event, we also
+ // set a flag to notify that metadata has been received. This
+ // allows for code that will miss the "metadata_received" event
+ // to know that metadata has been received. This is important in
+ // cases when some code will subscribe to the "metadata_received"
+ // event after it has been triggered.
+ self.youtubeMetadataReceived = true;
+ });
+}
+
+// function parseSpeed()
+//
+// Create a separate array of available speeds.
+function parseSpeed() {
+ this.speeds = _.keys(this.videos).sort();
+}
+
+function setSpeed(newSpeed) {
+ // Possible speeds for each player type.
+ // HTML5 = [0.75, 1, 1.25, 1.5, 2]
+ // Youtube Flash = [0.75, 1, 1.25, 1.5]
+ // Youtube HTML5 = [0.25, 0.5, 1, 1.5, 2]
+ let map = {
+ 0.25: '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash
+ '0.50': '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash
+ 0.75: '0.50', // HTML5 or Youtube Flash -> Youtube HTML5
+ 1.25: '1.50', // HTML5 or Youtube Flash -> Youtube HTML5
+ 2.0: '1.50' // HTML5 or Youtube HTML5 -> Youtube Flash
+ };
+
+ if (_.contains(this.speeds, newSpeed)) {
+ this.speed = newSpeed;
+ } else {
+ newSpeed = map[newSpeed];
+ this.speed = _.contains(this.speeds, newSpeed) ? newSpeed : '1.0';
+ }
+ this.speed = parseFloat(this.speed);
+}
+
+function setAutoAdvance(enabled) {
+ this.auto_advance = enabled;
+}
+
+function getVideoMetadata(url, callback) {
+ let youTubeEndpoint;
+ if (!(_.isString(url))) {
+ url = this.videos['1.0'] || '';
+ }
+ // Will hit the API URL to get the youtube video metadata.
+ youTubeEndpoint = this.config.ytMetadataEndpoint; // The new runtime supports anonymous users
+ // and uses an XBlock handler to get YouTube metadata
+ if (!youTubeEndpoint) {
+ // The old runtime has a full/separate LMS API for getting YouTube metadata, but it doesn't
+ // support anonymous users nor videos that play in a sandboxed iframe.
+ youTubeEndpoint = [this.config.lmsRootURL, '/courses/yt_video_metadata', '?id=', url].join('');
+ }
+ return $.ajax({
+ url: youTubeEndpoint,
+ success: _.isFunction(callback) ? callback : null,
+ error: function() {
+ console.warn(
+ 'Unable to get youtube video metadata. Some video metadata may be unavailable.'
+ );
+ },
+ notifyOnError: false
+ });
+}
+
+function youtubeId(speed) {
+ let currentSpeed = this.isFlashMode() ? this.speed : '1.0';
+
+ return this.videos[speed]
+ || this.videos[currentSpeed]
+ || this.videos['1.0'];
+}
+
+function getDuration() {
+ try {
+ let safeMoment = typeof moment !== 'undefined' ? moment : window.moment;
+ return safeMoment.duration(this.metadata[this.youtubeId()].duration, safeMoment.ISO_8601).asSeconds();
+ } catch (err) {
+ return _.result(this.metadata[this.youtubeId('1.0')], 'duration') || 0;
+ }
+}
+
+/**
+ * Sets player mode.
+ *
+ * @param {string} mode Mode to set for the video player if it is supported.
+ * Otherwise, `html5` is used by default.
+ */
+function setPlayerMode(mode) {
+ let supportedModes = ['html5', 'flash'];
+
+ mode = _.contains(supportedModes, mode) ? mode : 'html5';
+ this.currentPlayerMode = mode;
+}
+
+/**
+ * Returns current player mode.
+ *
+ * @return {string} Returns string that describes player mode
+ */
+function getPlayerMode() {
+ return this.currentPlayerMode;
+}
+
+/**
+ * Checks if current player mode is Flash.
+ *
+ * @return {boolean} Returns `true` if current mode is `flash`, otherwise
+ * it returns `false`
+ */
+function isFlashMode() {
+ return this.getPlayerMode() === 'flash';
+}
+
+/**
+ * Checks if current player mode is Html5.
+ *
+ * @return {boolean} Returns `true` if current mode is `html5`, otherwise
+ * it returns `false`
+ */
+function isHtml5Mode() {
+ return this.getPlayerMode() === 'html5';
+}
+
+function isYoutubeType() {
+ return this.videoType === 'youtube';
+}
+
+function speedToString(speed) {
+ return parseFloat(speed).toFixed(2).replace(/\.00$/, '.0');
+}
+
+function getCurrentLanguage() {
+ let keys = _.keys(this.config.transcriptLanguages);
+
+ if (keys.length) {
+ if (!_.contains(keys, this.lang)) {
+ if (_.contains(keys, 'en')) {
+ this.lang = 'en';
+ } else {
+ this.lang = keys.pop();
+ }
+ }
+ } else {
+ return null;
+ }
+
+ return this.lang;
+}
+
+/*
+ * The trigger() function will assume that the @objChain is a complete
+ * chain with a method (function) at the end. It will call this function.
+ * So for example, when trigger() is called like so:
+ *
+ * state.trigger('videoPlayer.pause', {'param1': 10});
+ *
+ * Then trigger() will execute:
+ *
+ * state.videoPlayer.pause({'param1': 10});
+ */
+function trigger(objChain) {
+ let extraParameters = Array.prototype.slice.call(arguments, 1),
+ i, tmpObj, chain;
+
+ // Remember that 'this' is the 'state' object.
+ tmpObj = this;
+ chain = objChain.split('.');
+
+ // At the end of the loop the variable 'tmpObj' will either be the
+ // correct object/function to trigger/invoke. If the 'chain' chain of
+ // object is incorrect (one of the link is non-existent), then the loop
+ // will immediately exit.
+ while (chain.length) {
+ i = chain.shift();
+
+ if (tmpObj.hasOwnProperty(i)) {
+ tmpObj = tmpObj[i];
+ } else {
+ // An incorrect object chain was specified.
+
+ return false;
+ }
+ }
+
+ tmpObj.apply(this, extraParameters);
+
+ return true;
+}
diff --git a/xmodule/assets/video/public/js/025_focus_grabber.js b/xmodule/assets/video/public/js/025_focus_grabber.js
new file mode 100644
index 0000000000..48ec5527ad
--- /dev/null
+++ b/xmodule/assets/video/public/js/025_focus_grabber.js
@@ -0,0 +1,132 @@
+/*
+ * 025_focus_grabber.js
+ *
+ * Purpose: Provide a way to focus on autohidden Video controls.
+ *
+ *
+ * Because in HTML player mode we have a feature of autohiding controls on
+ * mouse inactivity, sometimes focus is lost from the currently selected
+ * control. What's more, when all controls are autohidden, we can't get to any
+ * of them because by default browser does not place hidden elements on the
+ * focus chain.
+ *
+ * To get around this minor annoyance, this module will manage 2 placeholder
+ * elements that will be invisible to the user's eye, but visible to the
+ * browser. This will allow for a sneaky stealing of focus and placing it where
+ * we need (on hidden controls).
+ *
+ * This code has been moved to a separate module because it provides a concrete
+ * block of functionality that can be turned on (off).
+ */
+
+/*
+ * "If you want to climb a mountain, begin at the top."
+ *
+ * ~ Zen saying
+ */
+
+
+
+// FocusGrabber module.
+let FocusGrabber = function(state) {
+ let dfd = $.Deferred();
+
+ state.focusGrabber = {};
+
+ _makeFunctionsPublic(state);
+ _renderElements(state);
+ _bindHandlers(state);
+
+ dfd.resolve();
+ return dfd.promise();
+};
+
+// Private functions.
+
+function _makeFunctionsPublic(state) {
+ let methodsDict = {
+ disableFocusGrabber: disableFocusGrabber,
+ enableFocusGrabber: enableFocusGrabber,
+ onFocus: onFocus
+ };
+
+ state.bindTo(methodsDict, state.focusGrabber, state);
+}
+
+function _renderElements(state) {
+ state.focusGrabber.elFirst = state.el.find('.focus_grabber.first');
+ state.focusGrabber.elLast = state.el.find('.focus_grabber.last');
+
+ // From the start, the Focus Grabber must be disabled so that
+ // tabbing (switching focus) does not land the user on one of the
+ // placeholder elements (elFirst, elLast).
+ state.focusGrabber.disableFocusGrabber();
+}
+
+function _bindHandlers(state) {
+ state.focusGrabber.elFirst.on('focus', state.focusGrabber.onFocus);
+ state.focusGrabber.elLast.on('focus', state.focusGrabber.onFocus);
+
+ // When the video container element receives programmatic focus, then
+ // on un-focus ('blur' event) we should trigger a 'mousemove' event so
+ // as to reveal autohidden controls.
+ state.el.on('blur', function() {
+ state.el.trigger('mousemove');
+ });
+}
+
+// Public functions.
+
+function enableFocusGrabber() {
+ let tabIndex;
+
+ // When the Focus Grabber is being enabled, there are two different
+ // scenarios:
+ //
+ // 1.) Currently focused element was inside the video player.
+ // 2.) Currently focused element was somewhere else on the page.
+ //
+ // In the first case we must make sure that the video player doesn't
+ // loose focus, even though the controls are autohidden.
+ if ($(document.activeElement).parents().hasClass('video')) {
+ tabIndex = -1;
+ } else {
+ tabIndex = 0;
+ }
+
+ this.focusGrabber.elFirst.attr('tabindex', tabIndex);
+ this.focusGrabber.elLast.attr('tabindex', tabIndex);
+
+ // Don't loose focus. We are inside video player on some control, but
+ // because we can't remain focused on a hidden element, we will shift
+ // focus to the main video element.
+ //
+ // Once the main element will receive the un-focus ('blur') event, a
+ // 'mousemove' event will be triggered, and the video controls will
+ // receive focus once again.
+ if (tabIndex === -1) {
+ this.el.focus();
+
+ this.focusGrabber.elFirst.attr('tabindex', 0);
+ this.focusGrabber.elLast.attr('tabindex', 0);
+ }
+}
+
+function disableFocusGrabber() {
+ // Only programmatic focusing on these elements will be available.
+ // We don't want the user to focus on them (for example with the 'Tab'
+ // key).
+ this.focusGrabber.elFirst.attr('tabindex', -1);
+ this.focusGrabber.elLast.attr('tabindex', -1);
+}
+
+function onFocus(event, params) {
+ // Once the Focus Grabber placeholder elements will gain focus, we will
+ // trigger 'mousemove' event so that the autohidden controls will
+ // become visible.
+ this.el.trigger('mousemove');
+
+ this.focusGrabber.disableFocusGrabber();
+}
+
+export default FocusGrabber;
diff --git a/xmodule/assets/video/public/js/02_html5_hls_video.js b/xmodule/assets/video/public/js/02_html5_hls_video.js
new file mode 100644
index 0000000000..ddc198bc72
--- /dev/null
+++ b/xmodule/assets/video/public/js/02_html5_hls_video.js
@@ -0,0 +1,145 @@
+/* eslint-disable no-console, no-param-reassign */
+/**
+ * HTML5 video player module to support HLS video playback.
+ *
+ */
+
+'use strict';
+
+import _ from 'underscore';
+import HTML5Video from './02_html5_video.js';
+import HLS from 'hls';
+
+let HLSVideo = {};
+
+HLSVideo.Player = (function() {
+ /**
+ * Initialize HLS video player.
+ *
+ * @param {jQuery} el Reference to video player container element
+ * @param {Object} config Contains common config for video player
+ */
+ function Player(el, config) {
+ let self = this;
+
+ this.config = config;
+
+ // do common initialization independent of player type
+ this.init(el, config);
+
+ _.bindAll(this, 'playVideo', 'pauseVideo', 'onReady');
+
+ // If we have only HLS sources and browser doesn't support HLS then show error message.
+ if (config.HLSOnlySources && !config.canPlayHLS) {
+ this.showErrorMessage(null, '.video-hls-error');
+ return;
+ }
+
+ this.config.state.el.on('initialize', _.once(function() {
+ console.log('[HLS Video]: HLS Player initialized');
+ self.showPlayButton();
+ }));
+
+ // Safari has native support to play HLS videos
+ if (config.browserIsSafari) {
+ this.videoEl.attr('src', config.videoSources[0]);
+ } else {
+ // load auto start if auto_advance is enabled
+ if (config.state.auto_advance) {
+ this.hls = new HLS({autoStartLoad: true});
+ } else {
+ this.hls = new HLS({autoStartLoad: false});
+ }
+ this.hls.loadSource(config.videoSources[0]);
+ this.hls.attachMedia(this.video);
+
+ this.hls.on(HLS.Events.ERROR, this.onError.bind(this));
+
+ this.hls.on(HLS.Events.MANIFEST_PARSED, function(event, data) {
+ console.log(
+ '[HLS Video]: MANIFEST_PARSED, qualityLevelsInfo: ',
+ data.levels.map(function(level) {
+ return {
+ bitrate: level.bitrate,
+ resolution: level.width + 'x' + level.height
+ };
+ })
+ );
+ self.config.onReadyHLS();
+ });
+ this.hls.on(HLS.Events.LEVEL_SWITCHED, function(event, data) {
+ let level = self.hls.levels[data.level];
+ console.log(
+ '[HLS Video]: LEVEL_SWITCHED, qualityLevelInfo: ',
+ {
+ bitrate: level.bitrate,
+ resolution: level.width + 'x' + level.height
+ }
+ );
+ });
+ }
+ }
+
+ Player.prototype = Object.create(HTML5Video.Player.prototype);
+ Player.prototype.constructor = Player;
+
+ Player.prototype.playVideo = function() {
+ HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['show']);
+ if (!this.config.browserIsSafari) {
+ this.hls.startLoad();
+ }
+ HTML5Video.Player.prototype.playVideo.apply(this);
+ };
+
+ Player.prototype.pauseVideo = function() {
+ HTML5Video.Player.prototype.pauseVideo.apply(this);
+ HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['hide']);
+ };
+
+ Player.prototype.onPlaying = function() {
+ HTML5Video.Player.prototype.onPlaying.apply(this);
+ HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['hide']);
+ };
+
+ Player.prototype.onReady = function() {
+ this.config.events.onReady(null);
+ };
+
+ /**
+ * Handler for HLS video errors. This only takes care of fatal erros, non-fatal errors
+ * are automatically handled by hls.js
+ *
+ * @param {String} event `hlsError`
+ * @param {Object} data Contains the information regarding error occurred.
+ */
+ Player.prototype.onError = function(event, data) {
+ if (data.fatal) {
+ switch (data.type) {
+ case HLS.ErrorTypes.NETWORK_ERROR:
+ console.error(
+ '[HLS Video]: Fatal network error encountered, try to recover. Details: %s',
+ data.details
+ );
+ this.hls.startLoad();
+ break;
+ case HLS.ErrorTypes.MEDIA_ERROR:
+ console.error(
+ '[HLS Video]: Fatal media error encountered, try to recover. Details: %s',
+ data.details
+ );
+ this.hls.recoverMediaError();
+ break;
+ default:
+ console.error(
+ '[HLS Video]: Unrecoverable error encountered. Details: %s',
+ data.details
+ );
+ break;
+ }
+ }
+ };
+
+ return Player;
+}());
+
+export default HLSVideo;
diff --git a/xmodule/assets/video/public/js/02_html5_video.js b/xmodule/assets/video/public/js/02_html5_video.js
new file mode 100644
index 0000000000..8393720543
--- /dev/null
+++ b/xmodule/assets/video/public/js/02_html5_video.js
@@ -0,0 +1,380 @@
+/* eslint-disable no-console, no-param-reassign */
+/**
+ * @file HTML5 video player module. Provides methods to control the in-browser
+ * HTML5 video player.
+ *
+ * The goal was to write this module so that it closely resembles the YouTube
+ * API. The main reason for this is because initially the edX video player
+ * supported only YouTube videos. When HTML5 support was added, for greater
+ * compatibility, and to reduce the amount of code that needed to be modified,
+ * it was decided to write a similar API as the one provided by YouTube.
+ *
+ * @module HTML5Video
+ */
+
+import _ from 'underscore';
+
+let HTML5Video = {};
+
+HTML5Video.Player = (function() {
+ /*
+ * Constructor function for HTML5 Video player.
+ *
+ * @param {String|Object} el A DOM element where the HTML5 player will
+ * be inserted (as returned by jQuery(selector) function), or a
+ * selector string which will be used to select an element. This is a
+ * required parameter.
+ *
+ * @param config - An object whose properties will be used as
+ * configuration options for the HTML5 video player. This is an
+ * optional parameter. In the case if this parameter is missing, or
+ * some of the config object's properties are missing, defaults will be
+ * used. The available options (and their defaults) are as
+ * follows:
+ *
+ * config = {
+ *
+ * videoSources: [], // An array with properties being video
+ * // sources. The property name is the
+ * // video format of the source. Supported
+ * // video formats are: 'mp4', 'webm', and
+ * // 'ogg'.
+ * poster: Video poster URL
+ *
+ * browserIsSafari: Flag to tell if current browser is Safari
+ *
+ * events: { // Object's properties identify the
+ * // events that the API fires, and the
+ * // functions (event listeners) that the
+ * // API will call when those events occur.
+ * // If value is null, or property is not
+ * // specified, then no callback will be
+ * // called for that event.
+ *
+ * onReady: null,
+ * onStateChange: null
+ * }
+ * }
+ */
+ function Player(el, config) {
+ let errorMessage, lastSource, sourceList;
+
+ // Create HTML markup for individual sources of the HTML5