Merge branch 'master' into kireiev/AXM-549/feat/upstream_PR_active_inactive_courses_API
This commit is contained in:
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -17,6 +17,7 @@ lms/djangoapps/instructor_task/
|
||||
lms/djangoapps/mobile_api/
|
||||
openedx/core/djangoapps/credentials @openedx/2U-aperture
|
||||
openedx/core/djangoapps/credit @openedx/2U-aperture
|
||||
openedx/core/djangoapps/enrollments/ @openedx/2U-aperture
|
||||
openedx/core/djangoapps/heartbeat/
|
||||
openedx/core/djangoapps/oauth_dispatch
|
||||
openedx/core/djangoapps/user_api/ @openedx/2U-aperture
|
||||
@@ -37,8 +38,9 @@ lms/djangoapps/certificates/ @openedx/2U-
|
||||
# Discovery
|
||||
common/djangoapps/course_modes/
|
||||
common/djangoapps/enrollment/
|
||||
lms/djangoapps/branding/ @openedx/2U-aperture
|
||||
lms/djangoapps/commerce/
|
||||
lms/djangoapps/experiments/
|
||||
lms/djangoapps/experiments/ @openedx/2U-aperture
|
||||
lms/djangoapps/learner_dashboard/ @openedx/2U-aperture
|
||||
lms/djangoapps/learner_home/ @openedx/2U-aperture
|
||||
openedx/features/content_type_gating/
|
||||
|
||||
2
.github/workflows/ci-static-analysis.yml
vendored
2
.github/workflows/ci-static-analysis.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.11"
|
||||
os: ["ubuntu-20.04"]
|
||||
os: ["ubuntu-latest"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -15,7 +15,7 @@ defaults:
|
||||
|
||||
jobs:
|
||||
recompile-python-dependencies:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out target branch
|
||||
|
||||
2
.github/workflows/js-tests.yml
vendored
2
.github/workflows/js-tests.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-20.04]
|
||||
os: [ubuntu-latest]
|
||||
node-version: [18, 20]
|
||||
python-version:
|
||||
- "3.11"
|
||||
|
||||
2
.github/workflows/lint-imports.yml
vendored
2
.github/workflows/lint-imports.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
jobs:
|
||||
lint-imports:
|
||||
name: Lint Python Imports
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out branch
|
||||
|
||||
6
.github/workflows/migrations-check.yml
vendored
6
.github/workflows/migrations-check.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-20.04]
|
||||
os: [ubuntu-latest]
|
||||
python-version:
|
||||
- "3.11"
|
||||
# 'pinned' is used to install the latest patch version of Django
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup mongodb user
|
||||
run: |
|
||||
mongosh edxapp --eval '
|
||||
docker exec ${{ job.services.mongo.id }} mongosh edxapp --eval '
|
||||
db.createUser(
|
||||
{
|
||||
user: "edxapp",
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
- name: Verify mongo and mysql db credentials
|
||||
run: |
|
||||
mysql -h 127.0.0.1 -uedxapp001 -ppassword -e "select 1;" edxapp
|
||||
mongosh --host 127.0.0.1 --username edxapp --password password --eval 'use edxapp; db.adminCommand("ping");' edxapp
|
||||
docker exec ${{ job.services.mongo.id }} mongosh --host 127.0.0.1 --username edxapp --password password --eval 'use edxapp; db.adminCommand("ping");' edxapp
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
4
.github/workflows/pylint-checks.yml
vendored
4
.github/workflows/pylint-checks.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
run-pylint:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- module-name: openedx-1
|
||||
path: "--django-settings-module=lms.envs.test openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/content_staging/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/"
|
||||
- module-name: openedx-2
|
||||
path: "--django-settings-module=lms.envs.test openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/core/djangoapps/learner_pathway/ openedx/core/djangoapps/notifications/ openedx/core/djangoapps/staticfiles/ openedx/core/djangoapps/content_tagging/"
|
||||
path: "--django-settings-module=lms.envs.test openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/core/djangoapps/notifications/ openedx/core/djangoapps/staticfiles/ openedx/core/djangoapps/content_tagging/"
|
||||
- module-name: common
|
||||
path: "--django-settings-module=lms.envs.test common pavelib"
|
||||
- module-name: cms
|
||||
|
||||
2
.github/workflows/quality-checks.yml
vendored
2
.github/workflows/quality-checks.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-20.04]
|
||||
os: [ubuntu-latest]
|
||||
python-version:
|
||||
- "3.11"
|
||||
node-version: [20]
|
||||
|
||||
2
.github/workflows/semgrep.yml
vendored
2
.github/workflows/semgrep.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: "${{ matrix.os }}"
|
||||
strategy:
|
||||
matrix:
|
||||
os: ["ubuntu-20.04"]
|
||||
os: ["ubuntu-latest"]
|
||||
python-version:
|
||||
- "3.11"
|
||||
|
||||
|
||||
5
.github/workflows/static-assets-check.yml
vendored
5
.github/workflows/static-assets-check.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-20.04]
|
||||
os: [ubuntu-latest]
|
||||
python-version:
|
||||
- "3.11"
|
||||
node-version: [18, 20]
|
||||
@@ -72,9 +72,6 @@ jobs:
|
||||
run: |
|
||||
pip install -r requirements/edx/assets.txt
|
||||
|
||||
- name: Initiate Mongo DB Service
|
||||
run: sudo systemctl start mongod
|
||||
|
||||
- name: Add node_modules bin to $Path
|
||||
run: echo $GITHUB_WORKSPACE/node_modules/.bin >> $GITHUB_PATH
|
||||
|
||||
|
||||
36
.github/workflows/unit-tests.yml
vendored
36
.github/workflows/unit-tests.yml
vendored
@@ -15,7 +15,7 @@ concurrency:
|
||||
jobs:
|
||||
run-tests:
|
||||
name: ${{ matrix.shard_name }}(py=${{ matrix.python-version }},dj=${{ matrix.django-version }},mongo=${{ matrix.mongo-version }})
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
@@ -66,7 +66,29 @@ jobs:
|
||||
|
||||
- name: install system requirements
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install libmysqlclient-dev libxmlsec1-dev lynx
|
||||
sudo apt-get update && sudo apt-get install libmysqlclient-dev libxmlsec1-dev lynx openssl
|
||||
|
||||
# This is needed until the ENABLE_BLAKE2B_HASHING can be removed and we
|
||||
# can stop using MD4 by default.
|
||||
- name: enable md4 hashing in libssl
|
||||
run: |
|
||||
cat <<EOF | sudo tee /etc/ssl/openssl.cnf
|
||||
# Use this in order to automatically load providers.
|
||||
openssl_conf = openssl_init
|
||||
|
||||
[openssl_init]
|
||||
providers = provider_sect
|
||||
|
||||
[provider_sect]
|
||||
default = default_sect
|
||||
legacy = legacy_sect
|
||||
|
||||
[default_sect]
|
||||
activate = 1
|
||||
|
||||
[legacy_sect]
|
||||
activate = 1
|
||||
EOF
|
||||
|
||||
- name: install mongo version
|
||||
run: |
|
||||
@@ -142,7 +164,7 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
collect-and-verify:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Python
|
||||
@@ -207,7 +229,7 @@ jobs:
|
||||
# https://github.com/orgs/community/discussions/33579
|
||||
success:
|
||||
name: Unit tests successful
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [run-tests]
|
||||
steps:
|
||||
@@ -218,7 +240,7 @@ jobs:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
|
||||
compile-warnings-report:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
needs: [run-tests]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -246,7 +268,7 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
merge-artifacts:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compile-warnings-report]
|
||||
steps:
|
||||
- name: Merge Pytest Warnings JSON Artifacts
|
||||
@@ -266,7 +288,7 @@ jobs:
|
||||
# Combine and upload coverage reports.
|
||||
coverage:
|
||||
if: (github.repository == 'edx/edx-platform-private') || (github.repository == 'openedx/edx-platform' && (startsWith(github.base_ref, 'open-release') == false))
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
needs: [run-tests]
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
@@ -28,7 +28,7 @@ defaults:
|
||||
|
||||
jobs:
|
||||
upgrade-one-python-dependency:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out target branch
|
||||
|
||||
2
.github/workflows/verify-dunder-init.yml
vendored
2
.github/workflows/verify-dunder-init.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
jobs:
|
||||
verify_dunder_init:
|
||||
name: Verify __init__.py Files
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out branch
|
||||
|
||||
@@ -3,7 +3,7 @@ version: 2
|
||||
build:
|
||||
os: "ubuntu-22.04"
|
||||
tools:
|
||||
python: "3.8"
|
||||
python: "3.12"
|
||||
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
|
||||
33
README.rst
33
README.rst
@@ -124,6 +124,35 @@ sites)::
|
||||
./manage.py lms collectstatic
|
||||
./manage.py cms collectstatic
|
||||
|
||||
Set up CMS SSO (for Development)::
|
||||
|
||||
./manage.py lms manage_user studio_worker example@example.com --unusable-password
|
||||
# DO NOT DO THIS IN PRODUCTION. It will make your auth insecure.
|
||||
./manage.py lms create_dot_application studio-sso-id studio_worker \
|
||||
--grant-type authorization-code \
|
||||
--skip-authorization \
|
||||
--redirect-uris 'http://localhost:18010/complete/edx-oauth2/' \
|
||||
--scopes user_id \
|
||||
--client-id 'studio-sso-id' \
|
||||
--client-secret 'studio-sso-secret'
|
||||
|
||||
Set up CMS SSO (for Production):
|
||||
|
||||
* Create the CMS user and the OAuth application::
|
||||
|
||||
./manage.py lms manage_user studio_worker <email@yourcompany.com> --unusable-password
|
||||
./manage.py lms create_dot_application studio-sso-id studio_worker \
|
||||
--grant-type authorization-code \
|
||||
--skip-authorization \
|
||||
--redirect-uris 'http://localhost:18010/complete/edx-oauth2/' \
|
||||
--scopes user_id
|
||||
|
||||
* Log into Django admin (eg. http://localhost:18000/admin/oauth2_provider/application/),
|
||||
click into the application you created above (``studio-sso-id``), and copy its "Client secret".
|
||||
* In your private LMS_CFG yaml file or your private Django settings module:
|
||||
|
||||
* Set ``SOCIAL_AUTH_EDX_OAUTH2_KEY`` to the client ID (``studio-sso-id``).
|
||||
* Set ``SOCIAL_AUTH_EDX_OAUTH2_SECRET`` to the client secret (which you copied).
|
||||
Run the Platform
|
||||
----------------
|
||||
|
||||
@@ -131,11 +160,11 @@ First, ensure MySQL, Mongo, and Memcached are running.
|
||||
|
||||
Start the LMS::
|
||||
|
||||
./manage.py lms runserver
|
||||
./manage.py lms runserver 18000
|
||||
|
||||
Start the CMS::
|
||||
|
||||
./manage.py cms runserver
|
||||
./manage.py cms runserver 18010
|
||||
|
||||
This will give you a mostly-headless Open edX platform. Most frontends have
|
||||
been migrated to "Micro-Frontends (MFEs)" which need to be installed and run
|
||||
|
||||
@@ -51,6 +51,7 @@ class CourseHomeSerializer(serializers.Serializer):
|
||||
allow_empty=True
|
||||
)
|
||||
archived_courses = CourseCommonSerializer(required=False, many=True)
|
||||
can_access_advanced_settings = serializers.BooleanField()
|
||||
can_create_organizations = serializers.BooleanField()
|
||||
course_creator_status = serializers.CharField()
|
||||
courses = CourseCommonSerializer(required=False, many=True)
|
||||
|
||||
@@ -31,4 +31,3 @@ class CourseSettingsSerializer(serializers.Serializer):
|
||||
show_min_grade_warning = serializers.BooleanField()
|
||||
sidebar_html_enabled = serializers.BooleanField()
|
||||
upgrade_deadline = serializers.DateTimeField(allow_null=True)
|
||||
use_v2_cert_display_settings = serializers.BooleanField()
|
||||
|
||||
@@ -52,6 +52,7 @@ class HomePageView(APIView):
|
||||
"allow_unicode_course_id": false,
|
||||
"allowed_organizations": [],
|
||||
"archived_courses": [],
|
||||
"can_access_advanced_settings": true,
|
||||
"can_create_organizations": true,
|
||||
"course_creator_status": "granted",
|
||||
"courses": [],
|
||||
|
||||
@@ -95,7 +95,6 @@ class CourseSettingsView(DeveloperErrorViewMixin, APIView):
|
||||
"show_min_grade_warning": false,
|
||||
"sidebar_html_enabled": true,
|
||||
"upgrade_deadline": null,
|
||||
"use_v2_cert_display_settings": false
|
||||
}
|
||||
```
|
||||
"""
|
||||
@@ -112,7 +111,6 @@ class CourseSettingsView(DeveloperErrorViewMixin, APIView):
|
||||
'course_display_name_with_default': course_block.display_name_with_default,
|
||||
'platform_name': settings.PLATFORM_NAME,
|
||||
'licensing_enabled': settings.FEATURES.get("LICENSING", False),
|
||||
'use_v2_cert_display_settings': settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS", False),
|
||||
})
|
||||
|
||||
serializer = CourseSettingsSerializer(settings_context)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for course index outline.
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
@@ -62,7 +63,7 @@ class CourseIndexViewTest(CourseTestCase, PermissionAccessMixin):
|
||||
"advance_settings_url": f"/settings/advanced/{self.course.id}"
|
||||
},
|
||||
"discussions_incontext_feedback_url": "",
|
||||
"discussions_incontext_learnmore_url": "",
|
||||
"discussions_incontext_learnmore_url": settings.DISCUSSIONS_INCONTEXT_LEARNMORE_URL,
|
||||
"is_custom_relative_dates_active": True,
|
||||
"initial_state": None,
|
||||
"initial_user_clipboard": {
|
||||
@@ -103,7 +104,7 @@ class CourseIndexViewTest(CourseTestCase, PermissionAccessMixin):
|
||||
"advance_settings_url": f"/settings/advanced/{self.course.id}"
|
||||
},
|
||||
"discussions_incontext_feedback_url": "",
|
||||
"discussions_incontext_learnmore_url": "",
|
||||
"discussions_incontext_learnmore_url": settings.DISCUSSIONS_INCONTEXT_LEARNMORE_URL,
|
||||
"is_custom_relative_dates_active": False,
|
||||
"initial_state": {
|
||||
"expanded_locators": [
|
||||
|
||||
@@ -44,6 +44,7 @@ class HomePageViewTest(CourseTestCase):
|
||||
"allow_unicode_course_id": False,
|
||||
"allowed_organizations": [],
|
||||
"archived_courses": [],
|
||||
"can_access_advanced_settings": True,
|
||||
"can_create_organizations": True,
|
||||
"course_creator_status": "granted",
|
||||
"courses": [],
|
||||
|
||||
@@ -277,18 +277,14 @@ class ProctoringExamSettingsPostTests(
|
||||
|
||||
# response is correct
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
self.assertDictEqual(
|
||||
response.data,
|
||||
self.assertIn(
|
||||
{
|
||||
"detail": [
|
||||
{
|
||||
"proctoring_provider": (
|
||||
"The selected proctoring provider, notvalidprovider, is not a valid provider. "
|
||||
"Please select from one of ['test_proctoring_provider']."
|
||||
)
|
||||
}
|
||||
]
|
||||
"proctoring_provider": (
|
||||
"The selected proctoring provider, notvalidprovider, is not a valid provider. "
|
||||
"Please select from one of ['test_proctoring_provider']."
|
||||
)
|
||||
},
|
||||
response.data['detail'],
|
||||
)
|
||||
|
||||
# course settings have been updated
|
||||
@@ -408,18 +404,14 @@ class ProctoringExamSettingsPostTests(
|
||||
|
||||
# response is correct
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
self.assertDictEqual(
|
||||
response.data,
|
||||
self.assertIn(
|
||||
{
|
||||
"detail": [
|
||||
{
|
||||
"proctoring_provider": (
|
||||
"The selected proctoring provider, lti_external, is not a valid provider. "
|
||||
"Please select from one of ['null']."
|
||||
)
|
||||
}
|
||||
]
|
||||
"proctoring_provider": (
|
||||
"The selected proctoring provider, lti_external, is not a valid provider. "
|
||||
"Please select from one of ['null']."
|
||||
)
|
||||
},
|
||||
response.data['detail'],
|
||||
)
|
||||
|
||||
# course settings have been updated
|
||||
|
||||
@@ -55,7 +55,6 @@ class CourseSettingsViewTest(CourseTestCase, PermissionAccessMixin):
|
||||
"show_min_grade_warning": False,
|
||||
"upgrade_deadline": None,
|
||||
"licensing_enabled": False,
|
||||
"use_v2_cert_display_settings": False,
|
||||
}
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@@ -9,6 +9,7 @@ import ddt
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from edx_toggles.toggles.testutils import override_waffle_flag
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.locator import CourseLocator, LibraryLocator
|
||||
from path import Path as path
|
||||
@@ -19,7 +20,11 @@ from user_tasks.models import UserTaskArtifact, UserTaskStatus
|
||||
from cms.djangoapps.contentstore import utils
|
||||
from cms.djangoapps.contentstore.tasks import ALL_ALLOWED_XBLOCKS, validate_course_olx
|
||||
from cms.djangoapps.contentstore.tests.utils import TEST_DATA_DIR, CourseTestCase
|
||||
from cms.djangoapps.contentstore.utils import send_course_update_notification
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.student.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory
|
||||
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
|
||||
from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, Notification
|
||||
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context
|
||||
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
@@ -927,3 +932,32 @@ class UpdateCourseDetailsTests(ModuleStoreTestCase):
|
||||
|
||||
utils.update_course_details(mock_request, self.course.id, payload, None)
|
||||
mock_update.assert_called_once_with(self.course.id, payload, mock_request.user)
|
||||
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
class CourseUpdateNotificationTests(ModuleStoreTestCase):
|
||||
"""
|
||||
Unit tests for the course_update notification.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Setup the test environment.
|
||||
"""
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.course = CourseFactory.create(org='testorg', number='testcourse', run='testrun')
|
||||
CourseNotificationPreference.objects.create(user_id=self.user.id, course_id=self.course.id)
|
||||
|
||||
def test_course_update_notification_sent(self):
|
||||
"""
|
||||
Test that the course_update notification is sent.
|
||||
"""
|
||||
user = UserFactory()
|
||||
CourseEnrollment.enroll(user=user, course_key=self.course.id)
|
||||
assert Notification.objects.all().count() == 0
|
||||
content = "<p>content</p><img src='' />"
|
||||
send_course_update_notification(self.course.id, content, self.user)
|
||||
assert Notification.objects.all().count() == 1
|
||||
notification = Notification.objects.first()
|
||||
assert notification.content == "<p><strong><p>content</p></strong></p>"
|
||||
|
||||
@@ -11,16 +11,19 @@ from datetime import datetime, timezone
|
||||
from urllib.parse import quote_plus
|
||||
from uuid import uuid4
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.utils import translation
|
||||
from django.utils.text import Truncator
|
||||
from django.utils.translation import gettext as _
|
||||
from eventtracking import tracker
|
||||
from help_tokens.core import HelpUrlExpert
|
||||
from lti_consumer.models import CourseAllowPIISharingInLTIFlag
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from opaque_keys.edx.locator import LibraryLocator
|
||||
|
||||
from openedx.core.lib.teams_config import CONTENT_GROUPS_FOR_TEAMS, TEAM_SCHEME
|
||||
from openedx_events.content_authoring.data import DuplicatedXBlockData
|
||||
from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED
|
||||
@@ -1534,6 +1537,7 @@ def get_library_context(request, request_is_json=False):
|
||||
)
|
||||
from cms.djangoapps.contentstore.views.library import (
|
||||
LIBRARIES_ENABLED,
|
||||
user_can_view_create_library_button,
|
||||
)
|
||||
|
||||
libraries = _accessible_libraries_iter(request.user) if LIBRARIES_ENABLED else []
|
||||
@@ -1547,7 +1551,7 @@ def get_library_context(request, request_is_json=False):
|
||||
'in_process_course_actions': [],
|
||||
'courses': [],
|
||||
'libraries_enabled': LIBRARIES_ENABLED,
|
||||
'show_new_library_button': LIBRARIES_ENABLED and request.user.is_active,
|
||||
'show_new_library_button': user_can_view_create_library_button(request.user) and request.user.is_active,
|
||||
'user': request.user,
|
||||
'request_course_creator_url': reverse('request_course_creator'),
|
||||
'course_creator_status': _get_course_creator_status(request.user),
|
||||
@@ -1712,6 +1716,7 @@ def get_home_context(request, no_course=False):
|
||||
'allowed_organizations': get_allowed_organizations(user),
|
||||
'allowed_organizations_for_libraries': get_allowed_organizations_for_libraries(user),
|
||||
'can_create_organizations': user_can_create_organizations(user),
|
||||
'can_access_advanced_settings': auth.has_studio_advanced_settings_access(user),
|
||||
}
|
||||
|
||||
return home_context
|
||||
@@ -2239,11 +2244,34 @@ def track_course_update_event(course_key, user, course_update_content=None):
|
||||
tracker.emit(event_name, event_data)
|
||||
|
||||
|
||||
def clean_html_body(html_body):
|
||||
"""
|
||||
Get html body, remove tags and limit to 500 characters
|
||||
"""
|
||||
html_body = BeautifulSoup(Truncator(html_body).chars(500, html=True), 'html.parser')
|
||||
|
||||
tags_to_remove = [
|
||||
"a", "link", # Link Tags
|
||||
"img", "picture", "source", # Image Tags
|
||||
"video", "track", # Video Tags
|
||||
"audio", # Audio Tags
|
||||
"embed", "object", "iframe", # Embedded Content
|
||||
"script"
|
||||
]
|
||||
|
||||
# Remove the specified tags while keeping their content
|
||||
for tag in tags_to_remove:
|
||||
for match in html_body.find_all(tag):
|
||||
match.unwrap()
|
||||
|
||||
return str(html_body)
|
||||
|
||||
|
||||
def send_course_update_notification(course_key, content, user):
|
||||
"""
|
||||
Send course update notification
|
||||
"""
|
||||
text_content = re.sub(r"(\s| |//)+", " ", html_to_text(content))
|
||||
text_content = re.sub(r"(\s| |//)+", " ", clean_html_body(content))
|
||||
course = modulestore().get_course(course_key)
|
||||
extra_context = {
|
||||
'author_id': user.id,
|
||||
@@ -2252,10 +2280,10 @@ def send_course_update_notification(course_key, content, user):
|
||||
notification_data = CourseNotificationData(
|
||||
course_key=course_key,
|
||||
content_context={
|
||||
"course_update_content": text_content if len(text_content.strip()) < 10 else "Click here to view",
|
||||
"course_update_content": text_content,
|
||||
**extra_context,
|
||||
},
|
||||
notification_type="course_update",
|
||||
notification_type="course_updates",
|
||||
content_url=f"{settings.LMS_ROOT_URL}/courses/{str(course_key)}/course/updates",
|
||||
app_name="updates",
|
||||
audience_filters={},
|
||||
|
||||
@@ -69,31 +69,7 @@ def should_redirect_to_library_authoring_mfe():
|
||||
)
|
||||
|
||||
|
||||
def user_can_view_create_library_button(user):
|
||||
"""
|
||||
Helper method for displaying the visibilty of the create_library_button.
|
||||
"""
|
||||
if not LIBRARIES_ENABLED:
|
||||
return False
|
||||
elif user.is_staff:
|
||||
return True
|
||||
elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
|
||||
is_course_creator = get_course_creator_status(user) == 'granted'
|
||||
has_org_staff_role = OrgStaffRole().get_orgs_for_user(user).exists()
|
||||
has_course_staff_role = UserBasedRole(user=user, role=CourseStaffRole.ROLE).courses_with_role().exists()
|
||||
has_course_admin_role = UserBasedRole(user=user, role=CourseInstructorRole.ROLE).courses_with_role().exists()
|
||||
return is_course_creator or has_org_staff_role or has_course_staff_role or has_course_admin_role
|
||||
else:
|
||||
# EDUCATOR-1924: DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION, if present.
|
||||
disable_library_creation = settings.FEATURES.get('DISABLE_LIBRARY_CREATION', None)
|
||||
disable_course_creation = settings.FEATURES.get('DISABLE_COURSE_CREATION', False)
|
||||
if disable_library_creation is not None:
|
||||
return not disable_library_creation
|
||||
else:
|
||||
return not disable_course_creation
|
||||
|
||||
|
||||
def user_can_create_library(user, org):
|
||||
def _user_can_create_library_for_org(user, org=None):
|
||||
"""
|
||||
Helper method for returning the library creation status for a particular user,
|
||||
taking into account the value LIBRARIES_ENABLED.
|
||||
@@ -109,29 +85,29 @@ def user_can_create_library(user, org):
|
||||
Course Staff: Can make libraries in the organization which has courses of which they are staff.
|
||||
Course Admin: Can make libraries in the organization which has courses of which they are Admin.
|
||||
"""
|
||||
if org is None:
|
||||
return False
|
||||
if not LIBRARIES_ENABLED:
|
||||
return False
|
||||
elif user.is_staff:
|
||||
return True
|
||||
if settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
|
||||
elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
|
||||
org_filter_params = {}
|
||||
if org:
|
||||
org_filter_params['org'] = org
|
||||
is_course_creator = get_course_creator_status(user) == 'granted'
|
||||
has_org_staff_role = org in OrgStaffRole().get_orgs_for_user(user)
|
||||
has_org_staff_role = OrgStaffRole().get_orgs_for_user(user).filter(**org_filter_params).exists()
|
||||
has_course_staff_role = (
|
||||
UserBasedRole(user=user, role=CourseStaffRole.ROLE)
|
||||
.courses_with_role()
|
||||
.filter(org=org)
|
||||
.filter(**org_filter_params)
|
||||
.exists()
|
||||
)
|
||||
has_course_admin_role = (
|
||||
UserBasedRole(user=user, role=CourseInstructorRole.ROLE)
|
||||
.courses_with_role()
|
||||
.filter(org=org)
|
||||
.filter(**org_filter_params)
|
||||
.exists()
|
||||
)
|
||||
return is_course_creator or has_org_staff_role or has_course_staff_role or has_course_admin_role
|
||||
|
||||
else:
|
||||
# EDUCATOR-1924: DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION, if present.
|
||||
disable_library_creation = settings.FEATURES.get('DISABLE_LIBRARY_CREATION', None)
|
||||
@@ -142,6 +118,22 @@ def user_can_create_library(user, org):
|
||||
return not disable_course_creation
|
||||
|
||||
|
||||
def user_can_view_create_library_button(user):
|
||||
"""
|
||||
Helper method for displaying the visibilty of the create_library_button.
|
||||
"""
|
||||
return _user_can_create_library_for_org(user)
|
||||
|
||||
|
||||
def user_can_create_library(user, org):
|
||||
"""
|
||||
Helper method for to check if user can create library for given org.
|
||||
"""
|
||||
if org is None:
|
||||
return False
|
||||
return _user_can_create_library_for_org(user, org)
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@require_http_methods(('GET', 'POST'))
|
||||
|
||||
@@ -3674,14 +3674,15 @@ class TestSpecialExamXBlockInfo(ItemTest):
|
||||
@patch_does_backend_support_onboarding
|
||||
@patch_get_exam_by_content_id_success
|
||||
@ddt.data(
|
||||
("lti_external", False),
|
||||
("other_proctoring_backend", True),
|
||||
("lti_external", False, None),
|
||||
("other_proctoring_backend", True, "test_url"),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_support_onboarding_is_correct_depending_on_lti_external(
|
||||
def test_proctoring_values_correct_depending_on_lti_external(
|
||||
self,
|
||||
external_id,
|
||||
expected_value,
|
||||
expected_supports_onboarding_value,
|
||||
expected_proctoring_link,
|
||||
mock_get_exam_by_content_id,
|
||||
mock_does_backend_support_onboarding,
|
||||
_mock_get_exam_configuration_dashboard_url,
|
||||
@@ -3691,8 +3692,9 @@ class TestSpecialExamXBlockInfo(ItemTest):
|
||||
category="sequential",
|
||||
display_name="Test Lesson 1",
|
||||
user_id=self.user.id,
|
||||
is_proctored_enabled=False,
|
||||
is_time_limited=False,
|
||||
is_proctored_enabled=True,
|
||||
is_time_limited=True,
|
||||
default_time_limit_minutes=100,
|
||||
is_onboarding_exam=False,
|
||||
)
|
||||
|
||||
@@ -3709,7 +3711,8 @@ class TestSpecialExamXBlockInfo(ItemTest):
|
||||
include_children_predicate=ALWAYS,
|
||||
course=self.course,
|
||||
)
|
||||
assert xblock_info["supports_onboarding"] is expected_value
|
||||
assert xblock_info["supports_onboarding"] is expected_supports_onboarding_value
|
||||
assert xblock_info["proctoring_exam_configuration_link"] == expected_proctoring_link
|
||||
|
||||
@patch_get_exam_configuration_dashboard_url
|
||||
@patch_does_backend_support_onboarding
|
||||
@@ -3773,6 +3776,42 @@ class TestSpecialExamXBlockInfo(ItemTest):
|
||||
assert xblock_info["was_exam_ever_linked_with_external"] is False
|
||||
assert mock_get_exam_by_content_id.call_count == 1
|
||||
|
||||
@patch_get_exam_configuration_dashboard_url
|
||||
@patch_does_backend_support_onboarding
|
||||
@patch_get_exam_by_content_id_success
|
||||
def test_special_exam_xblock_info_get_dashboard_error(
|
||||
self,
|
||||
mock_get_exam_by_content_id,
|
||||
_mock_does_backend_support_onboarding,
|
||||
mock_get_exam_configuration_dashboard_url,
|
||||
):
|
||||
sequential = BlockFactory.create(
|
||||
parent_location=self.chapter.location,
|
||||
category="sequential",
|
||||
display_name="Test Lesson 1",
|
||||
user_id=self.user.id,
|
||||
is_proctored_enabled=True,
|
||||
is_time_limited=True,
|
||||
default_time_limit_minutes=100,
|
||||
is_onboarding_exam=False,
|
||||
)
|
||||
sequential = modulestore().get_item(sequential.location)
|
||||
mock_get_exam_configuration_dashboard_url.side_effect = Exception("proctoring error")
|
||||
xblock_info = create_xblock_info(
|
||||
sequential,
|
||||
include_child_info=True,
|
||||
include_children_predicate=ALWAYS,
|
||||
)
|
||||
|
||||
# no errors should be raised and proctoring_exam_configuration_link is None
|
||||
assert xblock_info["is_proctored_exam"] is True
|
||||
assert xblock_info["was_exam_ever_linked_with_external"] is True
|
||||
assert xblock_info["is_time_limited"] is True
|
||||
assert xblock_info["default_time_limit_minutes"] == 100
|
||||
assert xblock_info["proctoring_exam_configuration_link"] is None
|
||||
assert xblock_info["supports_onboarding"] is True
|
||||
assert xblock_info["is_onboarding_exam"] is False
|
||||
|
||||
|
||||
class TestLibraryXBlockInfo(ModuleStoreTestCase):
|
||||
"""
|
||||
|
||||
@@ -162,6 +162,39 @@ class TestExamSettingsView(CourseTestCase, UrlResetMixin):
|
||||
else:
|
||||
assert 'To update these settings go to the Advanced Settings page.' in alert_text
|
||||
|
||||
@override_settings(
|
||||
PROCTORING_BACKENDS={
|
||||
'DEFAULT': 'test_proctoring_provider',
|
||||
'proctortrack': {},
|
||||
'test_proctoring_provider': {},
|
||||
},
|
||||
FEATURES=FEATURES_WITH_EXAM_SETTINGS_ENABLED,
|
||||
)
|
||||
@ddt.data(
|
||||
"advanced_settings_handler",
|
||||
"course_handler",
|
||||
)
|
||||
def test_invalid_provider_alert(self, page_handler):
|
||||
"""
|
||||
An alert should appear if the course has a proctoring provider that is not valid.
|
||||
"""
|
||||
# create an error by setting an invalid proctoring provider
|
||||
self.course.proctoring_provider = 'invalid_provider'
|
||||
self.course.enable_proctored_exams = True
|
||||
self.save_course()
|
||||
|
||||
url = reverse_course_url(page_handler, self.course.id)
|
||||
resp = self.client.get(url, HTTP_ACCEPT='text/html')
|
||||
alert_text = self._get_exam_settings_alert_text(resp.content)
|
||||
assert (
|
||||
'This course has proctored exam settings that are incomplete or invalid.'
|
||||
in alert_text
|
||||
)
|
||||
assert (
|
||||
'The proctoring provider configured for this course, \'invalid_provider\', is not valid.'
|
||||
in alert_text
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
"advanced_settings_handler",
|
||||
"course_handler",
|
||||
|
||||
@@ -649,6 +649,9 @@ def _get_item(request, data):
|
||||
Returns the item.
|
||||
"""
|
||||
usage_key = UsageKey.from_string(data.get('locator'))
|
||||
if not usage_key.context_key.is_course:
|
||||
# TODO: implement transcript support for learning core / content libraries.
|
||||
raise TranscriptsRequestValidationException(_('Transcripts are not yet supported in content libraries.'))
|
||||
# This is placed before has_course_author_access() to validate the location,
|
||||
# because has_course_author_access() raises r if location is invalid.
|
||||
item = modulestore().get_item(usage_key)
|
||||
|
||||
@@ -1159,12 +1159,19 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements
|
||||
supports_onboarding = False
|
||||
|
||||
proctoring_exam_configuration_link = None
|
||||
if xblock.is_proctored_exam:
|
||||
proctoring_exam_configuration_link = (
|
||||
get_exam_configuration_dashboard_url(
|
||||
course.id, xblock_info["id"]
|
||||
|
||||
# only call get_exam_configuration_dashboard_url if not using an LTI proctoring provider
|
||||
if xblock.is_proctored_exam and (course.proctoring_provider != 'lti_external'):
|
||||
try:
|
||||
proctoring_exam_configuration_link = (
|
||||
get_exam_configuration_dashboard_url(
|
||||
course.id, xblock_info["id"]
|
||||
)
|
||||
)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.error(
|
||||
f"Error while getting proctoring exam configuration link: {e}"
|
||||
)
|
||||
)
|
||||
|
||||
if course.proctoring_provider == "proctortrack":
|
||||
show_review_rules = SHOW_REVIEW_RULES_FLAG.is_enabled(
|
||||
|
||||
@@ -217,7 +217,10 @@ class CourseMetadata:
|
||||
try:
|
||||
val = model['value']
|
||||
if hasattr(block, key) and getattr(block, key) != val:
|
||||
key_values[key] = block.fields[key].from_json(val)
|
||||
if key == 'proctoring_provider':
|
||||
key_values[key] = block.fields[key].from_json(val, validate_providers=True)
|
||||
else:
|
||||
key_values[key] = block.fields[key].from_json(val)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ValueError(_("Incorrect format for field '{name}'. {detailed_message}").format( # lint-amnesty, pylint: disable=raise-missing-from
|
||||
name=model['display_name'], detailed_message=str(err)))
|
||||
@@ -253,7 +256,10 @@ class CourseMetadata:
|
||||
try:
|
||||
val = model['value']
|
||||
if hasattr(block, key) and getattr(block, key) != val:
|
||||
key_values[key] = block.fields[key].from_json(val)
|
||||
if key == 'proctoring_provider':
|
||||
key_values[key] = block.fields[key].from_json(val, validate_providers=True)
|
||||
else:
|
||||
key_values[key] = block.fields[key].from_json(val)
|
||||
except (TypeError, ValueError, ValidationError) as err:
|
||||
did_validate = False
|
||||
errors.append({'key': key, 'message': str(err), 'model': model})
|
||||
@@ -484,6 +490,24 @@ class CourseMetadata:
|
||||
enable_proctoring = block.enable_proctored_exams
|
||||
|
||||
if enable_proctoring:
|
||||
|
||||
if proctoring_provider_model:
|
||||
proctoring_provider = proctoring_provider_model.get('value')
|
||||
else:
|
||||
proctoring_provider = block.proctoring_provider
|
||||
|
||||
# If the proctoring provider stored in the course block no longer
|
||||
# matches the available providers for this instance, show an error
|
||||
if proctoring_provider not in available_providers:
|
||||
message = (
|
||||
f'The proctoring provider configured for this course, \'{proctoring_provider}\', is not valid.'
|
||||
)
|
||||
errors.append({
|
||||
'key': 'proctoring_provider',
|
||||
'message': message,
|
||||
'model': proctoring_provider_model
|
||||
})
|
||||
|
||||
# Require a valid escalation email if Proctortrack is chosen as the proctoring provider
|
||||
escalation_email_model = settings_dict.get('proctoring_escalation_email')
|
||||
if escalation_email_model:
|
||||
@@ -491,11 +515,6 @@ class CourseMetadata:
|
||||
else:
|
||||
escalation_email = block.proctoring_escalation_email
|
||||
|
||||
if proctoring_provider_model:
|
||||
proctoring_provider = proctoring_provider_model.get('value')
|
||||
else:
|
||||
proctoring_provider = block.proctoring_provider
|
||||
|
||||
missing_escalation_email_msg = 'Provider \'{provider}\' requires an exam escalation contact.'
|
||||
if proctoring_provider_model and proctoring_provider == 'proctortrack':
|
||||
if not escalation_email:
|
||||
|
||||
@@ -468,17 +468,6 @@ FEATURES = {
|
||||
# .. toggle_tickets: https://github.com/openedx/edx-platform/pull/26106
|
||||
'ENABLE_HELP_LINK': True,
|
||||
|
||||
# .. toggle_name: FEATURES['ENABLE_V2_CERT_DISPLAY_SETTINGS']
|
||||
# .. toggle_implementation: DjangoSetting
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Whether to use the reimagined certificates_display_behavior and certificate_available_date
|
||||
# .. settings. Will eventually become the default.
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2021-07-26
|
||||
# .. toggle_target_removal_date: 2021-10-01
|
||||
# .. toggle_tickets: 'https://openedx.atlassian.net/browse/MICROBA-1405'
|
||||
'ENABLE_V2_CERT_DISPLAY_SETTINGS': False,
|
||||
|
||||
# .. toggle_name: FEATURES['ENABLE_INTEGRITY_SIGNATURE']
|
||||
# .. toggle_implementation: DjangoSetting
|
||||
# .. toggle_default: False
|
||||
@@ -949,7 +938,6 @@ MIDDLEWARE = [
|
||||
'openedx.core.djangoapps.cache_toolbox.middleware.CacheBackedAuthenticationMiddleware',
|
||||
|
||||
'common.djangoapps.student.middleware.UserStandingMiddleware',
|
||||
'openedx.core.djangoapps.contentserver.middleware.StaticContentServerMiddleware',
|
||||
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'common.djangoapps.track.middleware.TrackMiddleware',
|
||||
@@ -1449,9 +1437,8 @@ base_vendor_js = [
|
||||
'edx-ui-toolkit/js/utils/string-utils.js',
|
||||
'edx-ui-toolkit/js/utils/html-utils.js',
|
||||
|
||||
# Load Bootstrap and supporting libraries
|
||||
'common/js/vendor/popper.js',
|
||||
'common/js/vendor/bootstrap.js',
|
||||
# Here we were loading Bootstrap and supporting libraries, but it no longer seems to be needed for any Studio UI.
|
||||
# 'common/js/vendor/bootstrap.bundle.js',
|
||||
|
||||
# Finally load RequireJS
|
||||
'common/js/vendor/require.js'
|
||||
@@ -1880,6 +1867,7 @@ INSTALLED_APPS = [
|
||||
'openedx_events',
|
||||
|
||||
# Learning Core Apps, used by v2 content libraries (content_libraries app)
|
||||
"openedx_learning.apps.authoring.collections",
|
||||
"openedx_learning.apps.authoring.components",
|
||||
"openedx_learning.apps.authoring.contents",
|
||||
"openedx_learning.apps.authoring.publishing",
|
||||
@@ -2814,8 +2802,14 @@ EDX_BRAZE_API_SERVER = None
|
||||
|
||||
BRAZE_COURSE_ENROLLMENT_CANVAS_ID = ''
|
||||
|
||||
######################## Discussion Forum settings ########################
|
||||
|
||||
# Feedback link in upgraded discussion notification alert
|
||||
DISCUSSIONS_INCONTEXT_FEEDBACK_URL = ''
|
||||
DISCUSSIONS_INCONTEXT_LEARNMORE_URL = ''
|
||||
|
||||
# Learn More link in upgraded discussion notification alert
|
||||
# pylint: disable=line-too-long
|
||||
DISCUSSIONS_INCONTEXT_LEARNMORE_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/manage_discussions/discussions.html"
|
||||
|
||||
#### django-simple-history##
|
||||
# disable indexing on date field its coming django-simple-history.
|
||||
@@ -2937,3 +2931,10 @@ MEILISEARCH_PUBLIC_URL = "http://meilisearch.example.com"
|
||||
# See https://www.meilisearch.com/docs/learn/security/tenant_tokens
|
||||
MEILISEARCH_INDEX_PREFIX = ""
|
||||
MEILISEARCH_API_KEY = "devkey"
|
||||
|
||||
# .. setting_name: DISABLED_COUNTRIES
|
||||
# .. setting_default: []
|
||||
# .. setting_description: List of country codes that should be disabled
|
||||
# .. for now it wil impact country listing in auth flow and user profile.
|
||||
# .. eg ['US', 'CA']
|
||||
DISABLED_COUNTRIES = []
|
||||
|
||||
@@ -267,7 +267,8 @@ WEBPACK_LOADER['DEFAULT']['TIMEOUT'] = 5
|
||||
################ Using LMS SSO for login to Studio ################
|
||||
SOCIAL_AUTH_EDX_OAUTH2_KEY = 'studio-sso-key'
|
||||
SOCIAL_AUTH_EDX_OAUTH2_SECRET = 'studio-sso-secret' # in stage, prod would be high-entropy secret
|
||||
SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT = 'http://edx.devstack.lms:18000' # routed internally server-to-server
|
||||
# routed internally server-to-server
|
||||
SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT = ENV_TOKENS.get('SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT', 'http://edx.devstack.lms:18000')
|
||||
SOCIAL_AUTH_EDX_OAUTH2_PUBLIC_URL_ROOT = 'http://localhost:18000' # used in browser redirect
|
||||
|
||||
# Don't form the return redirect URL with HTTPS on devstack
|
||||
|
||||
@@ -689,3 +689,10 @@ SPECTACULAR_SETTINGS = {
|
||||
}
|
||||
|
||||
BEAMER_PRODUCT_ID = ENV_TOKENS.get('BEAMER_PRODUCT_ID', BEAMER_PRODUCT_ID)
|
||||
|
||||
# .. setting_name: DISABLED_COUNTRIES
|
||||
# .. setting_default: []
|
||||
# .. setting_description: List of country codes that should be disabled
|
||||
# .. for now it wil impact country listing in auth flow and user profile.
|
||||
# .. eg ['US', 'CA']
|
||||
DISABLED_COUNTRIES = ENV_TOKENS.get('DISABLED_COUNTRIES', [])
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
// +Libs and Resets - *do not edit*
|
||||
// ====================
|
||||
|
||||
@import '_builtin-block-variables';
|
||||
@import 'bourbon/bourbon'; // lib - bourbon
|
||||
@import 'vendor/bi-app/bi-app-ltr'; // set the layout for left to right languages
|
||||
@import 'build-v1'; // shared app style assets/rendering
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
<base target="_blank">
|
||||
<meta charset="UTF-8">
|
||||
<!-- gettext & XBlock JS i18n code -->
|
||||
<script type="text/javascript" src="{{ lms_root_url }}/static/js/i18n/en/djangojs.js"></script>
|
||||
{% if is_development %}
|
||||
<!-- in development, the djangojs file isn't available so use fallback-->
|
||||
<script type="text/javascript" src="{{ lms_root_url }}/static/js/src/gettext_fallback.js"></script>
|
||||
{% else %}
|
||||
<script type="text/javascript" src="{{ lms_root_url }}/static/js/i18n/en/djangojs.js"></script>
|
||||
{% endif %}
|
||||
<!-- Most XBlocks require jQuery: -->
|
||||
<script src="https://code.jquery.com/jquery-2.2.4.min.js"></script>
|
||||
<!-- The Video XBlock requires "ajaxWithPrefix" -->
|
||||
|
||||
@@ -162,7 +162,7 @@ from django.urls import reverse
|
||||
% if mfe_proctored_exam_settings_url:
|
||||
<% url_encoded_course_id = quote(str(context_course.id).encode('utf-8'), safe='') %>
|
||||
${Text(_("To update these settings go to the {link_start}Proctored Exam Settings page{link_end}.")).format(
|
||||
link_start=HTML('<a href="${mfe_proctored_exam_settings_url}">').format(
|
||||
link_start=HTML('<a href="{mfe_proctored_exam_settings_url}">').format(
|
||||
mfe_proctored_exam_settings_url=mfe_proctored_exam_settings_url
|
||||
),
|
||||
link_end=HTML("</a>")
|
||||
|
||||
@@ -43,7 +43,6 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url | n, js_escaped_string}'
|
||||
${show_min_grade_warning | n, dump_js_escaped_json},
|
||||
${can_show_certificate_available_date_field(context_course) | n, dump_js_escaped_json},
|
||||
"${upgrade_deadline | n, js_escaped_string}",
|
||||
${settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS") | n, dump_js_escaped_json}
|
||||
);
|
||||
});
|
||||
</%block>
|
||||
@@ -251,58 +250,45 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url | n, js_escaped_string}'
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<%
|
||||
use_v2_cert_display_settings = settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS", False)
|
||||
%>
|
||||
% if can_show_certificate_available_date_field(context_course):
|
||||
<ol class="list-input">
|
||||
<li class="field-group field-group-certificate-available" id="certificate-available">
|
||||
<div class="field date" id="field-certificates-display-behavior">
|
||||
<label for="certificates-display-behavior">${_("Certificates Display Behavior")}</label>
|
||||
% if use_v2_cert_display_settings:
|
||||
<select id="certificates-display-behavior">
|
||||
<option value="early_no_info">${_("Immediately upon passing")}</option>
|
||||
<option value="end">${_("End date of course")}</option>
|
||||
<option value="end_with_date">${_("A date after the course end date")}</option>
|
||||
</select>
|
||||
% else:
|
||||
<input id="certificates-display-behavior" type="text">
|
||||
% endif
|
||||
<select id="certificates-display-behavior">
|
||||
<option value="early_no_info">${_("Immediately upon passing")}</option>
|
||||
<option value="end">${_("End date of course")}</option>
|
||||
<option value="end_with_date">${_("A date after the course end date")}</option>
|
||||
</select>
|
||||
<span class="tip tip-stacked">${_("Certificates are awarded at the end of a course run")}</span>
|
||||
|
||||
% if use_v2_cert_display_settings:
|
||||
<!-- Collapsible -->
|
||||
<div class="collapsible">
|
||||
<div id="certificate-display-behavior-collapsible-trigger" class="collapsible-trigger" role="button" tabindex="0" aria-expanded="false">
|
||||
<span>
|
||||
<span class="icon icon-inline fa fa-info-circle" aria-hidden="true"></span>
|
||||
${_("Read more about this setting")}
|
||||
</span>
|
||||
<!-- Collapsible -->
|
||||
<div class="collapsible">
|
||||
<div id="certificate-display-behavior-collapsible-trigger" class="collapsible-trigger" role="button" tabindex="0" aria-expanded="false">
|
||||
<span>
|
||||
<span class="icon icon-inline fa fa-info-circle" aria-hidden="true"></span>
|
||||
${_("Read more about this setting")}
|
||||
</span>
|
||||
</div>
|
||||
<div id="certificate-display-behavior-collapsible-content" class="collapsible-content collapsed">
|
||||
<p>${_("In all configurations of this setting, certificates are generated for learners as soon as they achieve the passing threshold in the course (which can occur before a final assignment based on course design)")}</p>
|
||||
<div>
|
||||
<div class="collapsible-description-heading">${_("Immediately upon passing")}</div>
|
||||
<div class="collapsible-description-description">${_("Learners can access their certificate as soon as they achieve a passing grade above the course grade threshold. Note: learners can achieve a passing grade before encountering all assignments in some course configurations.")}</div>
|
||||
</div>
|
||||
<div id="certificate-display-behavior-collapsible-content" class="collapsible-content collapsed">
|
||||
<p>${_("In all configurations of this setting, certificates are generated for learners as soon as they achieve the passing threshold in the course (which can occur before a final assignment based on course design)")}</p>
|
||||
<div>
|
||||
<div class="collapsible-description-heading">${_("Immediately upon passing")}</div>
|
||||
<div class="collapsible-description-description">${_("Learners can access their certificate as soon as they achieve a passing grade above the course grade threshold. Note: learners can achieve a passing grade before encountering all assignments in some course configurations.")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="collapsible-description-heading">${_("On course end date")}</div>
|
||||
<div class="collapsible-description-description">${_("Learners with passing grades can access their certificate once the end date of the course has elapsed.")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="collapsible-description-heading">${_("A date after the course end date")}</div>
|
||||
<div class="collapsible-description-description">${_("Learners with passing grades can access their certificate after the date that you set has elapsed.")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="collapsible-description-heading">${_("On course end date")}</div>
|
||||
<div class="collapsible-description-description">${_("Learners with passing grades can access their certificate once the end date of the course has elapsed.")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="collapsible-description-heading">${_("A date after the course end date")}</div>
|
||||
<div class="collapsible-description-description">${_("Learners with passing grades can access their certificate after the date that you set has elapsed.")}</div>
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
% if use_v2_cert_display_settings:
|
||||
<div class="field date hidden" id="field-certificate-available-date" >
|
||||
% else:
|
||||
<div class="field date" id="field-certificate-available-date" >
|
||||
% endif
|
||||
<div class="field date hidden" id="field-certificate-available-date" >
|
||||
<label for="certificate-available-date">${_("Certificates Available Date")}</label>
|
||||
<input type="text" class="certificate-available-date date start datepicker" id="certificate-available-date" placeholder="MM/DD/YYYY" autocomplete="off" />
|
||||
<span class="icon icon-inline fa fa-calendar-check-o datepicker-icon" aria-hidden="true"></span>
|
||||
|
||||
@@ -126,9 +126,13 @@ class ChooseModeView(View):
|
||||
if ecommerce_service.is_enabled(request.user):
|
||||
professional_mode = modes.get(CourseMode.NO_ID_PROFESSIONAL_MODE) or modes.get(CourseMode.PROFESSIONAL)
|
||||
if purchase_workflow == "single" and professional_mode.sku:
|
||||
redirect_url = ecommerce_service.get_checkout_page_url(professional_mode.sku)
|
||||
redirect_url = ecommerce_service.get_checkout_page_url(
|
||||
professional_mode.sku, course_run_keys=[course_id]
|
||||
)
|
||||
if purchase_workflow == "bulk" and professional_mode.bulk_sku:
|
||||
redirect_url = ecommerce_service.get_checkout_page_url(professional_mode.bulk_sku)
|
||||
redirect_url = ecommerce_service.get_checkout_page_url(
|
||||
professional_mode.bulk_sku, course_run_keys=[course_id]
|
||||
)
|
||||
return redirect(redirect_url)
|
||||
course = modulestore().get_course(course_key)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ requiring Superuser access for all other Request types on an API endpoint.
|
||||
"""
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
from lms.djangoapps.courseware.access import has_access
|
||||
@@ -22,12 +21,3 @@ class IsAdminOrSupportOrAuthenticatedReadOnly(BasePermission):
|
||||
return request.user.is_authenticated
|
||||
else:
|
||||
return request.user.is_staff or has_access(request.user, "support", "global")
|
||||
|
||||
|
||||
class IsSubscriptionWorkerUser(BasePermission):
|
||||
"""
|
||||
Method that will require the request to be coming from the subscriptions service worker user.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.user.username == settings.SUBSCRIPTIONS_SERVICE_WORKER_USERNAME
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
@@ -1236,160 +1235,3 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
|
||||
assert CourseEnrollment.is_enrolled(self.user, self.course.id)
|
||||
assert course_entitlement.enrollment_course_run is not None
|
||||
assert course_entitlement.expired_at is None
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class RevokeSubscriptionsVerifiedAccessViewTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the RevokeVerifiedAccessView
|
||||
"""
|
||||
REVOKE_VERIFIED_ACCESS_PATH = 'entitlements_api:v1:revoke_subscriptions_verified_access'
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = UserFactory(username="subscriptions_worker", is_staff=True)
|
||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||
self.course = CourseFactory()
|
||||
self.course_mode1 = CourseModeFactory(
|
||||
course_id=self.course.id, # pylint: disable=no-member
|
||||
mode_slug=CourseMode.VERIFIED,
|
||||
expiration_datetime=now() + timedelta(days=1)
|
||||
)
|
||||
self.course_mode2 = CourseModeFactory(
|
||||
course_id=self.course.id, # pylint: disable=no-member
|
||||
mode_slug=CourseMode.AUDIT,
|
||||
expiration_datetime=now() + timedelta(days=1)
|
||||
)
|
||||
|
||||
@patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status')
|
||||
def test_revoke_access_success(self, mock_get_courses_completion_status):
|
||||
mock_get_courses_completion_status.return_value = ([], False)
|
||||
enrollment = CourseEnrollmentFactory.create(
|
||||
user=self.user,
|
||||
course_id=self.course.id, # pylint: disable=no-member
|
||||
is_active=True,
|
||||
mode=CourseMode.VERIFIED
|
||||
)
|
||||
course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment)
|
||||
url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH)
|
||||
|
||||
assert course_entitlement.enrollment_course_run is not None
|
||||
|
||||
response = self.client.post(
|
||||
url,
|
||||
data={
|
||||
"entitlement_uuids": [str(course_entitlement.uuid)],
|
||||
"lms_user_id": self.user.id
|
||||
},
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
course_entitlement.refresh_from_db()
|
||||
enrollment.refresh_from_db()
|
||||
assert course_entitlement.expired_at is not None
|
||||
assert course_entitlement.enrollment_course_run is None
|
||||
assert enrollment.mode == CourseMode.AUDIT
|
||||
|
||||
@patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status')
|
||||
def test_already_completed_course(self, mock_get_courses_completion_status):
|
||||
enrollment = CourseEnrollmentFactory.create(
|
||||
user=self.user,
|
||||
course_id=self.course.id, # pylint: disable=no-member
|
||||
is_active=True,
|
||||
mode=CourseMode.VERIFIED
|
||||
)
|
||||
mock_get_courses_completion_status.return_value = ([str(enrollment.course_id)], False)
|
||||
course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment)
|
||||
url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH)
|
||||
|
||||
assert course_entitlement.enrollment_course_run is not None
|
||||
|
||||
response = self.client.post(
|
||||
url,
|
||||
data={
|
||||
"entitlement_uuids": [str(course_entitlement.uuid)],
|
||||
"lms_user_id": self.user.id
|
||||
},
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
course_entitlement.refresh_from_db()
|
||||
assert course_entitlement.expired_at is None
|
||||
assert course_entitlement.enrollment_course_run.mode == CourseMode.VERIFIED
|
||||
|
||||
@patch('common.djangoapps.entitlements.rest_api.v1.views.log.info')
|
||||
def test_revoke_access_invalid_uuid(self, mock_log):
|
||||
url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH)
|
||||
entitlement_uuids = [str(uuid4())]
|
||||
response = self.client.post(
|
||||
url,
|
||||
data={
|
||||
"entitlement_uuids": entitlement_uuids,
|
||||
"lms_user_id": self.user.id
|
||||
},
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
mock_log.assert_called_once_with("B2C_SUBSCRIPTIONS: Entitlements not found for the provided"
|
||||
" entitlements data: %s and user: %s",
|
||||
entitlement_uuids,
|
||||
self.user.id)
|
||||
assert response.status_code == 204
|
||||
|
||||
def test_revoke_access_unauthorized_user(self):
|
||||
user = UserFactory(is_staff=True, username='not_subscriptions_worker')
|
||||
self.client.login(username=user.username, password=TEST_PASSWORD)
|
||||
|
||||
enrollment = CourseEnrollmentFactory.create(
|
||||
user=self.user,
|
||||
course_id=self.course.id, # pylint: disable=no-member
|
||||
is_active=True,
|
||||
mode=CourseMode.VERIFIED
|
||||
)
|
||||
course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment)
|
||||
url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH)
|
||||
|
||||
assert course_entitlement.enrollment_course_run is not None
|
||||
|
||||
response = self.client.post(
|
||||
url,
|
||||
data={
|
||||
"entitlement_uuids": [],
|
||||
"lms_user_id": self.user.id
|
||||
},
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
course_entitlement.refresh_from_db()
|
||||
assert course_entitlement.expired_at is None
|
||||
assert course_entitlement.enrollment_course_run.mode == CourseMode.VERIFIED
|
||||
|
||||
@patch('common.djangoapps.entitlements.tasks.retry_revoke_subscriptions_verified_access.apply_async')
|
||||
@patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status')
|
||||
def test_course_completion_exception_triggers_task(self, mock_get_courses_completion_status, mock_task):
|
||||
mock_get_courses_completion_status.return_value = ([], True)
|
||||
enrollment = CourseEnrollmentFactory.create(
|
||||
user=self.user,
|
||||
course_id=self.course.id, # pylint: disable=no-member
|
||||
is_active=True,
|
||||
mode=CourseMode.VERIFIED
|
||||
)
|
||||
course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment)
|
||||
|
||||
url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH)
|
||||
|
||||
response = self.client.post(
|
||||
url,
|
||||
data={
|
||||
"entitlement_uuids": [str(course_entitlement.uuid)],
|
||||
"lms_user_id": self.user.id
|
||||
},
|
||||
content_type='application/json',
|
||||
)
|
||||
assert response.status_code == 204
|
||||
mock_task.assert_called_once_with(args=([str(course_entitlement.uuid)],
|
||||
[str(enrollment.course_id)],
|
||||
self.user.username))
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""
|
||||
Throttle classes for the entitlements API.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
|
||||
|
||||
class ServiceUserThrottle(UserRateThrottle):
|
||||
"""A throttle allowing service users to override rate limiting"""
|
||||
|
||||
def allow_request(self, request, view):
|
||||
"""Returns True if the request is coming from one of the service users
|
||||
and defaults to UserRateThrottle's configured setting otherwise.
|
||||
"""
|
||||
service_users = [
|
||||
settings.SUBSCRIPTIONS_SERVICE_WORKER_USERNAME
|
||||
]
|
||||
if request.user.username in service_users:
|
||||
return True
|
||||
return super().allow_request(request, view)
|
||||
@@ -6,7 +6,7 @@ from django.urls import include
|
||||
from django.urls import path, re_path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import EntitlementEnrollmentViewSet, EntitlementViewSet, SubscriptionsRevokeVerifiedAccessView
|
||||
from .views import EntitlementEnrollmentViewSet, EntitlementViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'entitlements', EntitlementViewSet, basename='entitlements')
|
||||
@@ -24,9 +24,4 @@ urlpatterns = [
|
||||
ENROLLMENTS_VIEW,
|
||||
name='enrollments'
|
||||
),
|
||||
path(
|
||||
'subscriptions/entitlements/revoke',
|
||||
SubscriptionsRevokeVerifiedAccessView.as_view(),
|
||||
name='revoke_subscriptions_verified_access'
|
||||
)
|
||||
]
|
||||
|
||||
@@ -15,7 +15,6 @@ from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from common.djangoapps.entitlements.models import ( # lint-amnesty, pylint: disable=line-too-long
|
||||
@@ -24,22 +23,13 @@ from common.djangoapps.entitlements.models import ( # lint-amnesty, pylint: dis
|
||||
CourseEntitlementSupportDetail
|
||||
)
|
||||
from common.djangoapps.entitlements.rest_api.v1.filters import CourseEntitlementFilter
|
||||
from common.djangoapps.entitlements.rest_api.v1.permissions import (
|
||||
IsAdminOrSupportOrAuthenticatedReadOnly,
|
||||
IsSubscriptionWorkerUser
|
||||
)
|
||||
from common.djangoapps.entitlements.rest_api.v1.permissions import IsAdminOrSupportOrAuthenticatedReadOnly
|
||||
from common.djangoapps.entitlements.rest_api.v1.serializers import CourseEntitlementSerializer
|
||||
from common.djangoapps.entitlements.rest_api.v1.throttles import ServiceUserThrottle
|
||||
from common.djangoapps.entitlements.tasks import retry_revoke_subscriptions_verified_access
|
||||
from common.djangoapps.entitlements.utils import (
|
||||
is_course_run_entitlement_fulfillable,
|
||||
revoke_entitlements_and_downgrade_courses_to_audit
|
||||
)
|
||||
from common.djangoapps.entitlements.utils import is_course_run_entitlement_fulfillable
|
||||
from common.djangoapps.student.models import AlreadyEnrolledError, CourseEnrollment, CourseEnrollmentException
|
||||
from openedx.core.djangoapps.catalog.utils import get_course_runs_for_course, get_owners_for_course
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
|
||||
from openedx.core.djangoapps.credentials.utils import get_courses_completion_status
|
||||
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
|
||||
|
||||
User = get_user_model()
|
||||
@@ -132,7 +122,6 @@ class EntitlementViewSet(viewsets.ModelViewSet):
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filterset_class = CourseEntitlementFilter
|
||||
pagination_class = EntitlementsPagination
|
||||
throttle_classes = (ServiceUserThrottle,)
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
@@ -530,68 +519,3 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
|
||||
})
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class SubscriptionsRevokeVerifiedAccessView(APIView):
|
||||
"""
|
||||
Endpoint for expiring entitlements for a user and downgrading the enrollments
|
||||
to Audit mode. This endpoint accepts a list of entitlement UUIDs and will expire
|
||||
the entitlements along with downgrading the related enrollments to Audit mode.
|
||||
Only those enrollments are downgraded to Audit for which user has not been awarded
|
||||
a completion certificate yet.
|
||||
"""
|
||||
authentication_classes = (JwtAuthentication, SessionAuthenticationCrossDomainCsrf,)
|
||||
permission_classes = (permissions.IsAuthenticated, IsSubscriptionWorkerUser,)
|
||||
throttle_classes = (ServiceUserThrottle,)
|
||||
|
||||
def _process_revoke_and_downgrade_to_audit(self, course_entitlements, user_id, revocable_entitlement_uuids):
|
||||
"""
|
||||
Gets course completion status for the provided course entitlements and triggers the
|
||||
revoke and downgrade to audit process for the course entitlements which are not completed.
|
||||
Triggers the retry task asynchronously if there is an exception while getting the
|
||||
course completion status.
|
||||
"""
|
||||
entitled_course_ids = []
|
||||
user = User.objects.get(id=user_id)
|
||||
username = user.username
|
||||
for course_entitlement in course_entitlements:
|
||||
if course_entitlement.enrollment_course_run is not None:
|
||||
entitled_course_ids.append(str(course_entitlement.enrollment_course_run.course_id))
|
||||
|
||||
log.info('B2C_SUBSCRIPTIONS: Getting course completion status for user [%s] and entitled_course_ids %s',
|
||||
username,
|
||||
entitled_course_ids)
|
||||
awarded_cert_course_ids, is_exception = get_courses_completion_status(username, entitled_course_ids)
|
||||
|
||||
if is_exception:
|
||||
# Trigger the retry task asynchronously
|
||||
log.exception('B2C_SUBSCRIPTIONS: Exception occurred while getting course completion status for user %s '
|
||||
'and entitled_course_ids %s',
|
||||
username,
|
||||
entitled_course_ids)
|
||||
retry_revoke_subscriptions_verified_access.apply_async(args=(revocable_entitlement_uuids,
|
||||
entitled_course_ids,
|
||||
username))
|
||||
return
|
||||
revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, username, awarded_cert_course_ids,
|
||||
revocable_entitlement_uuids)
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
Invokes the entitlements expiration process for the provided uuids and downgrades the
|
||||
enrollments to Audit mode.
|
||||
"""
|
||||
revocable_entitlement_uuids = request.data.get('entitlement_uuids', [])
|
||||
user_id = request.data.get('lms_user_id', None)
|
||||
course_entitlements = (CourseEntitlement.objects.filter(uuid__in=revocable_entitlement_uuids).
|
||||
select_related('user').
|
||||
select_related('enrollment_course_run'))
|
||||
|
||||
if course_entitlements.exists():
|
||||
self._process_revoke_and_downgrade_to_audit(course_entitlements, user_id, revocable_entitlement_uuids)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
log.info('B2C_SUBSCRIPTIONS: Entitlements not found for the provided entitlements data: %s and user: %s',
|
||||
revocable_entitlement_uuids,
|
||||
user_id)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -4,15 +4,12 @@ This file contains celery tasks for entitlements-related functionality.
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.conf import settings # lint-amnesty, pylint: disable=unused-import
|
||||
from django.contrib.auth import get_user_model
|
||||
from edx_django_utils.monitoring import set_code_owner_attribute
|
||||
|
||||
from common.djangoapps.entitlements.models import CourseEntitlement, CourseEntitlementSupportDetail
|
||||
from common.djangoapps.entitlements.utils import revoke_entitlements_and_downgrade_courses_to_audit
|
||||
from openedx.core.djangoapps.credentials.utils import get_courses_completion_status
|
||||
|
||||
LOGGER = get_task_logger(__name__)
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -154,40 +151,3 @@ def expire_and_create_entitlements(self, entitlement_ids, support_username):
|
||||
'%d entries, task id :%s',
|
||||
len(entitlement_ids),
|
||||
self.request.id)
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
@set_code_owner_attribute
|
||||
def retry_revoke_subscriptions_verified_access(self, revocable_entitlement_uuids, entitled_course_ids, username):
|
||||
"""
|
||||
Task to process course access revoke and move to audit.
|
||||
This is called only if call to get_courses_completion_status fails due to any exception.
|
||||
"""
|
||||
LOGGER.info("B2C_SUBSCRIPTIONS: Running retry_revoke_subscriptions_verified_access for user [%s],"
|
||||
" entitlement_uuids %s and entitled_course_ids %s",
|
||||
username,
|
||||
revocable_entitlement_uuids,
|
||||
entitled_course_ids)
|
||||
course_entitlements = CourseEntitlement.objects.filter(uuid__in=revocable_entitlement_uuids)
|
||||
course_entitlements = course_entitlements.select_related('user').select_related('enrollment_course_run')
|
||||
if course_entitlements.exists():
|
||||
awarded_cert_course_ids, is_exception = get_courses_completion_status(username, entitled_course_ids)
|
||||
if is_exception:
|
||||
try:
|
||||
countdown = 2 ** self.request.retries
|
||||
self.retry(countdown=countdown, max_retries=3)
|
||||
except MaxRetriesExceededError:
|
||||
LOGGER.exception(
|
||||
'B2C_SUBSCRIPTIONS: Failed to process retry_revoke_subscriptions_verified_access '
|
||||
'for user [%s] and entitlement_uuids %s',
|
||||
username,
|
||||
revocable_entitlement_uuids
|
||||
)
|
||||
return
|
||||
revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, username, awarded_cert_course_ids,
|
||||
revocable_entitlement_uuids)
|
||||
else:
|
||||
LOGGER.info('B2C_SUBSCRIPTIONS: Entitlements not found for the provided entitlements uuids %s '
|
||||
'for user [%s] duing the retry_revoke_subscriptions_verified_access task',
|
||||
revocable_entitlement_uuids,
|
||||
username)
|
||||
|
||||
@@ -646,21 +646,14 @@ def _is_certificate_earned_but_not_available(course_overview, status):
|
||||
(bool): True if the user earned the certificate but it's hidden due to display behavior, else False
|
||||
|
||||
"""
|
||||
if settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS"):
|
||||
return (
|
||||
not certificates_viewable_for_course(course_overview)
|
||||
and CertificateStatuses.is_passing_status(status)
|
||||
and course_overview.certificates_display_behavior in (
|
||||
CertificatesDisplayBehaviors.END_WITH_DATE,
|
||||
CertificatesDisplayBehaviors.END
|
||||
)
|
||||
)
|
||||
else:
|
||||
return (
|
||||
not certificates_viewable_for_course(course_overview) and
|
||||
CertificateStatuses.is_passing_status(status) and
|
||||
course_overview.certificate_available_date
|
||||
return (
|
||||
not certificates_viewable_for_course(course_overview)
|
||||
and CertificateStatuses.is_passing_status(status)
|
||||
and course_overview.certificates_display_behavior in (
|
||||
CertificatesDisplayBehaviors.END_WITH_DATE,
|
||||
CertificatesDisplayBehaviors.END
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def process_survey_link(survey_link, user):
|
||||
|
||||
@@ -583,7 +583,7 @@ class SetIDVerificationStatusTestCase(TestCase):
|
||||
"""
|
||||
Verification signal is sent upon approval.
|
||||
"""
|
||||
with mock.patch('openedx.core.djangoapps.signals.signals.LEARNER_NOW_VERIFIED.send_robust') as mock_signal:
|
||||
with mock.patch('openedx_events.learning.signals.IDV_ATTEMPT_APPROVED.send_event') as mock_signal:
|
||||
# Begin the pipeline.
|
||||
pipeline.set_id_verification_status(
|
||||
auth_entry=pipeline.AUTH_ENTRY_LOGIN,
|
||||
|
||||
Binary file not shown.
73
common/static/sass/_builtin-block-variables.scss
Normal file
73
common/static/sass/_builtin-block-variables.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* In pursuit of decoupling the built-in XBlocks from edx-platform's Sass build
|
||||
* and ensuring comprehensive theming support in the extracted XBlocks,
|
||||
* we need to expose Sass variables as CSS variables.
|
||||
*
|
||||
* Ticket/Issue: https://github.com/openedx/edx-platform/issues/35173
|
||||
*/
|
||||
@import 'bourbon/bourbon';
|
||||
@import 'lms/theme/variables';
|
||||
@import 'lms/theme/variables-v1';
|
||||
@import 'cms/static/sass/partials/cms/theme/_variables';
|
||||
@import 'cms/static/sass/partials/cms/theme/_variables-v1';
|
||||
@import 'bootstrap/scss/variables';
|
||||
@import 'vendor/bi-app/bi-app-ltr';
|
||||
@import 'edx-pattern-library-shims/base/_variables.scss';
|
||||
|
||||
:root {
|
||||
--action-primary-active-bg: $action-primary-active-bg;
|
||||
--all-text-inputs: $all-text-inputs;
|
||||
--base-font-size: $base-font-size;
|
||||
--base-line-height: $base-line-height;
|
||||
--baseline: $baseline;
|
||||
--black: $black;
|
||||
--black-t2: $black-t2;
|
||||
--blue: $blue;
|
||||
--blue-d1: $blue-d1;
|
||||
--blue-d2: $blue-d2;
|
||||
--blue-d4: $blue-d4;
|
||||
--body-color: $body-color;
|
||||
--border-color: $border-color;
|
||||
--bp-screen-lg: $bp-screen-lg;
|
||||
--btn-brand-focus-background: $btn-brand-focus-background;
|
||||
--correct: $correct;
|
||||
--danger: $danger;
|
||||
--darkGrey: $darkGrey;
|
||||
--error-color: $error-color;
|
||||
--font-bold: $font-bold;
|
||||
--font-family-sans-serif: $font-family-sans-serif;
|
||||
--general-color-accent: $general-color-accent;
|
||||
--gray: $gray;
|
||||
--gray-300: $gray-300;
|
||||
--gray-d1: $gray-d1;
|
||||
--gray-l2: $gray-l2;
|
||||
--gray-l3: $gray-l3;
|
||||
--gray-l4: $gray-l4;
|
||||
--gray-l6: $gray-l6;
|
||||
--incorrect: $incorrect;
|
||||
--lightGrey: $lightGrey;
|
||||
--lighter-base-font-color: $lighter-base-font-color;
|
||||
--link-color: $link-color;
|
||||
--medium-font-size: $medium-font-size;
|
||||
--partially-correct: $partially-correct;
|
||||
--primary: $primary;
|
||||
--shadow: $shadow;
|
||||
--shadow-l1: $shadow-l1;
|
||||
--sidebar-color: $sidebar-color;
|
||||
--small-font-size: $small-font-size;
|
||||
--static-path: $static-path;
|
||||
--submitted: $submitted;
|
||||
--success: $success;
|
||||
--tmg-f2: $tmg-f2;
|
||||
--tmg-s2: $tmg-s2;
|
||||
--transparent: $transparent;
|
||||
--uxpl-gray-background: $uxpl-gray-background;
|
||||
--uxpl-gray-base: $uxpl-gray-base;
|
||||
--uxpl-gray-dark: $uxpl-gray-dark;
|
||||
--very-light-text: $very-light-text;
|
||||
--warning: $warning;
|
||||
--warning-color: $warning-color;
|
||||
--warning-color-accent: $warning-color-accent;
|
||||
--white: $white;
|
||||
--yellow: $yellow;
|
||||
}
|
||||
19
docs/conf.py
19
docs/conf.py
@@ -83,11 +83,11 @@ try:
|
||||
except git.InvalidGitRepositoryError:
|
||||
edx_platform_version = "master"
|
||||
|
||||
featuretoggles_source_path = edxplatform_source_path
|
||||
featuretoggles_source_path = str(edxplatform_source_path)
|
||||
featuretoggles_repo_url = edxplatform_repo_url
|
||||
featuretoggles_repo_version = edx_platform_version
|
||||
|
||||
settings_source_path = edxplatform_source_path
|
||||
settings_source_path = str(edxplatform_source_path)
|
||||
settings_repo_url = edxplatform_repo_url
|
||||
settings_repo_version = edx_platform_version
|
||||
|
||||
@@ -108,7 +108,7 @@ master_doc = 'index'
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
language = 'en'
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
@@ -170,7 +170,7 @@ html_theme_options = {
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
# html_static_path = ['_static']
|
||||
|
||||
# Custom sidebar templates, must be a dictionary that maps document names
|
||||
# to template names.
|
||||
@@ -258,13 +258,22 @@ epub_title = project
|
||||
epub_exclude_files = ['search.html']
|
||||
|
||||
|
||||
# -- Read the Docs Specific Configuration
|
||||
# Define the canonical URL if you are using a custom domain on Read the Docs
|
||||
html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "")
|
||||
|
||||
# Tell Jinja2 templates the build is running on Read the Docs
|
||||
if os.environ.get("READTHEDOCS", "") == "True":
|
||||
if "html_context" not in globals():
|
||||
html_context = {}
|
||||
html_context["READTHEDOCS"] = True
|
||||
|
||||
# -- Extension configuration -------------------------------------------------
|
||||
|
||||
# -- Options for intersphinx extension ---------------------------------------
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {
|
||||
'https://docs.python.org/2.7': None,
|
||||
'django': ('https://docs.djangoproject.com/en/1.11/', 'https://docs.djangoproject.com/en/1.11/_objects/'),
|
||||
}
|
||||
|
||||
|
||||
402
docs/decisions/0020-upstream-downstream.rst
Normal file
402
docs/decisions/0020-upstream-downstream.rst
Normal file
@@ -0,0 +1,402 @@
|
||||
4. Upstream and downstream content
|
||||
##################################
|
||||
|
||||
Status
|
||||
******
|
||||
|
||||
Accepted.
|
||||
|
||||
Implementation in progress as of 2024-09-03.
|
||||
|
||||
Context
|
||||
*******
|
||||
|
||||
We are replacing the existing Legacy ("V1") Content Libraries system, based on
|
||||
ModuleStore, with a Relaunched ("V2") Content Libraries system, based on
|
||||
Learning Core. V1 and V2 libraries will coexist for at least one release to
|
||||
allow for migration; eventually, V1 libraries will be removed entirely.
|
||||
|
||||
Content from V1 libraries can only be included into courses using the
|
||||
LibraryContentBlock (called "Randomized Content Module" in Studio), which works
|
||||
like this:
|
||||
|
||||
* Course authors add a LibraryContentBlock to a Unit and configure it with a
|
||||
library key and a count of N library blocks to select (or `-1` for "all
|
||||
blocks").
|
||||
|
||||
* For each block in the chosen library, its *content definition* is copied into
|
||||
the course as a child of the LibraryContentBlock, whereas its *settings* are
|
||||
copied into a special "default" settings dictionary in the course's structure
|
||||
document--this distinction will matter later. The usage key of each copied
|
||||
block is derived from a hash of the original library block's usage key plus
|
||||
the LibraryContentBlock's own usage key--this will also matter
|
||||
later.
|
||||
|
||||
* The course author is free to override the content and settings of the
|
||||
course-local copies of each library block.
|
||||
|
||||
* When any update is made to the library, the course author is prompted to
|
||||
update the LibraryContentBlock. This involves re-copying the library blocks'
|
||||
content definitions and default settings, which clobbers any overrides they
|
||||
have made to content, but preserves any overrides they have made to settings.
|
||||
Furthermore, any blocks that were added to the library are newly copied into
|
||||
the course, and any blocks that were removed from the library are deleted
|
||||
from the course. For all blocks, usage keys are recalculated using the same
|
||||
hash derivation described above; for existing blocks, it is important that
|
||||
this recalculation yields the same usage key so that student state is not
|
||||
lost.
|
||||
|
||||
* Over in the LMS, when a learner loads LibraryContentBlock, they are shown a
|
||||
list of N randomly-picked blocks from the library. Subsequent visits show
|
||||
them the same list, *unless* children were added, children were removed, or N
|
||||
changed. In those cases, the LibraryContentBlock tries to make the smallest
|
||||
possible adjustment to their personal list of blocks while respecting N and
|
||||
the updated list of children.
|
||||
|
||||
This system has several issues:
|
||||
|
||||
#. **Missing defaults after import:** When a course with a LibraryContentBlock
|
||||
is imported into an Open edX instance *without* the referenced library, the
|
||||
blocks' *content* will remain intact as will course-local *settings
|
||||
overrides*. However, any *default settings* defined in the library will be
|
||||
missing. This can result in content that is completely broken, especially
|
||||
since critical fields like video URLs and LTI URLs are considered
|
||||
"settings". For a detailed scenario, see `LibraryContentBlock Curveball 1`_.
|
||||
|
||||
#. **Strange behavior when duplicating content:** Typically, when a
|
||||
block is duplicated or copy-pasted, the new block's usage key and its
|
||||
children's usage keys are randomly generated. However, recall that when a
|
||||
LibraryContentBlock is updated, its children's usage keys are rederived
|
||||
using a hash function. That would cause the children's usage keys to change,
|
||||
thus destroying any student state. So, we must work around this with a hack:
|
||||
upon duplicating or pasting a LibraryContentBlock, we immediately update the
|
||||
LibraryContentBlock, thus discarding the problematic randomly-generated keys
|
||||
in favor of hash-derived keys. This works, but:
|
||||
|
||||
* it involves weird code hacks,
|
||||
* it unexpectedly discards any content overrides the course author made to
|
||||
the copied LibraryContentBlock's children,
|
||||
* it unexpectedly uses the latest version of library content, regardless of
|
||||
which version the copied LibraryContentBlock was using, and
|
||||
* it fails if the library does not exist on the Open edX instance, which
|
||||
can happen if the course was imported from another instance.
|
||||
|
||||
#. **Conflation of reference and randomization:** The LibraryContentBlock does
|
||||
two things: it connects courses to library content, and it shows users a
|
||||
random subset of content. There is no reason that those two features need to
|
||||
be coupled together. A course author may want to randomize course-defined
|
||||
content, or they may want to randomize content from multiple different
|
||||
libraries. Or, they may want to use content from libraries without
|
||||
randomizing it at all. While it is feasible to support all these things in a
|
||||
single XBlock, trying to do so led to a `very complicated XBlock concept`_
|
||||
which difficult to explain to product managers and other engineers.
|
||||
|
||||
#. **Unpredictable preservation of overrides:** Recall that *content
|
||||
definitions* and *settings* are handled differently. This distinction is
|
||||
defined in the code: every authorable XBlock field is either defined with
|
||||
`Scope.content` or `Scope.settings`. In theory, XBlock developers would use
|
||||
the content scope for fields that are core to the meaning of piece of
|
||||
content, and they would only use the settings scope for fields that would be
|
||||
reasonable to configure in a local copy of the piece of content. In
|
||||
practice, though, XBlock developers almost always use `Scope.settings`. The
|
||||
result of this is that customizations to blocks *almost always* survive
|
||||
through library updates, except when they don't. Course authors have no way
|
||||
to know (or even guess) when their customizations they will and won't
|
||||
survive updates.
|
||||
|
||||
#. **General pain and suffering:** The relationship between courses and V1
|
||||
libraries is confusing to content authors, site admins, and developers
|
||||
alike. The behaviors above toe the line between "quirks" and "known bugs",
|
||||
and they are not all documented. Past attempts to improve the system have
|
||||
`triggered series of bugs`_, some of which led to permanent loss of learner
|
||||
state. In other cases, past Content Libraries improvement efforts have
|
||||
slowed or completely stalled out in code review due to the overwhelming
|
||||
amount of context and edge cases that must be understood to safely make any
|
||||
changes.
|
||||
|
||||
.. _LibraryContentBlock Curveball 1: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3966795804/Fun+with+LibraryContentBlock+export+import+and+duplication#Curveball-1%3A-Import%2FExport
|
||||
.. _LibraryContentBlock Curveball 2: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3966795804/Fun+with+LibraryContentBlock+export+import+and+duplication#Curveball-2:-Duplication
|
||||
.. _very complicated XBlock concept: https://github.com/openedx/edx-platform/blob/master/xmodule/docs/decisions/0003-library-content-block-schema.rst
|
||||
.. _triggered series of bugs: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3858661405/Bugs+from+Content+Libraries+V1
|
||||
|
||||
We are keen to use the Library Relaunch project to address all of these
|
||||
problems. So, V2 libraries will interop with courses using a completely
|
||||
different data model.
|
||||
|
||||
|
||||
Decision
|
||||
********
|
||||
|
||||
We will create a framework where a *downstream* piece of content (e.g. a course
|
||||
block) can be *linked* to an *upstream* piece of content (e.g., a library
|
||||
block) with the following properties:
|
||||
|
||||
* **Portable:** Links can refer to certain content on the current Open edX
|
||||
instance, and in the future they may be able to refer to content on other
|
||||
Open edX instances or sites. Links will never include information that is
|
||||
internal to a particular Open edX instance, such as foreign keys.
|
||||
|
||||
* **Flat:** The *link* is a not a wrapper (like the LibraryContentBlock),
|
||||
but simply a piece of metadata directly on the downstream content which
|
||||
points to the upstream content. We will no longer rely on precarious
|
||||
hash-derived usage keys to establish connection to upstream blocks;
|
||||
like any other block, an upstream-linked blocks can be granted whatever block
|
||||
ID that the authoring environment assigns it, whether random or
|
||||
human-readable.
|
||||
|
||||
* **Forwards-compatible:** If downstream content is created in a course on
|
||||
an Open edX site that supports upstream and downstreams (e.g., a Teak
|
||||
instance), and then it is exported and imported into a site that doesn't
|
||||
(e.g., a Quince instance), the downstream content will simply act like
|
||||
regular course content.
|
||||
|
||||
* **Independent:** Upstream content and downstream content exist separately
|
||||
from one another:
|
||||
|
||||
* Modifying upstream content does not affect any downstream content (unless a
|
||||
sync happens, more on that later).
|
||||
* Deleting upstream content does not impact its downstream content. By
|
||||
corollary, pieces of downstream content can completely and correctly render
|
||||
on Open edX instances that are missing their linked upstream content.
|
||||
* (Preserving a positive feature of the V1 LibraryContentBlock) The link
|
||||
persists through export-import and copy-paste, regardless of whether the
|
||||
upstream content actually exists. A "broken" link to upstream content is
|
||||
seamlessly "repaired" if the upstream content becomes available again.
|
||||
|
||||
* **Customizable:** On an OLX level, authors can still override the value
|
||||
of any field for a piece of downstream content. However, we will empower
|
||||
Studio to be more prescriptive about what authors *can* override versus what
|
||||
they *should* override:
|
||||
|
||||
* We define a set of *customizable* fields, with platform-level defaults
|
||||
like display_name and a max_attempts, plus the ability for external
|
||||
XBlocks to opt their own fields into customizability.
|
||||
* Studio may use this list to provide an interface for customizing
|
||||
downstream blocks, separate from the usual "Edit" interface that would
|
||||
permit them to make unsafe overrides.
|
||||
* Furthermore, downstream content will record which fields the user has
|
||||
customized...
|
||||
|
||||
* even if the customization is to simply clear the value of the fields...
|
||||
* and even if the customization is made redundant in a future version of
|
||||
the upstream content. For example, if max_attempts is customized from 3
|
||||
to 5 in the downstream content, but the next version of the upstream
|
||||
content also changes max_attempts to 5, the downstream would still
|
||||
consider max_attempts to be customized. If the following version of the
|
||||
upstream content again changed max_attempts to 6, the downstream would
|
||||
retain max_attempts to be 5.
|
||||
|
||||
* Finally, the downstream content will locally save the upstream value of
|
||||
customizable fields, allowing the author to *revert* back to them
|
||||
regardless of whether the upstream content is actually available.
|
||||
|
||||
* **Synchronizable, without surprises:** Downstream content can be *synced*
|
||||
with updates that have been made to its linked upstream. This means that the
|
||||
latest available upstream content field values will entirely replace all of
|
||||
the downstream field values, *except* those which were customized, as
|
||||
described in the previous item.
|
||||
|
||||
* **Concrete, but flexible:** The internal implementation of upstream-downstream
|
||||
syncing will assume that:
|
||||
|
||||
* upstream content belongs to a V2 content library,
|
||||
* downstream content belongs to a course on the same instance, and
|
||||
* the link is the stringified usage key of the upstream library content.
|
||||
|
||||
This will allow us to keep the implementation straightforward. However, we
|
||||
will *not* expose these assumptions in the Python APIs, the HTTP APIs, or in
|
||||
the persisted fields, allowing us in the future to generalize to other
|
||||
upstreams (such as externally-hosted libraries) and other downstreams (such
|
||||
as a standalone enrollable sequence without a course).
|
||||
|
||||
If any of these assumptions are violated, we will raise an exception or log a
|
||||
warning, as appropriate. Particularly, if these assumptions are violated at
|
||||
the OLX level via a course import, then we will probably show a warning at
|
||||
import time and refuse to sync from the unsupported upstream; however, we
|
||||
will *not* fail the entire import or mangle the value of upstream link, since
|
||||
we want to remain forwards-compatible with potential future forms of syncing.
|
||||
As a concrete example: if a course block has *another course block's usage
|
||||
key* as an upstream, then we will faithfully keep that value through the
|
||||
import and export process, but we will not prompt the user to sync updates
|
||||
for that block.
|
||||
|
||||
* **Decoupled:** Upstream-downstream linking is not tied up with any other
|
||||
courseware feature; in particular, it is unrelated to content randomization.
|
||||
Randomized library content will be supported, but it will be a *synthesis* of
|
||||
two features: (1) a RandomizationBlock that randomly selects a subset of its
|
||||
children, where (2) some or all of those children are linked to upstream
|
||||
blocks.
|
||||
|
||||
Consequences
|
||||
************
|
||||
|
||||
To support the Libraries Relaunch in Sumac:
|
||||
|
||||
* For every XBlock in CMS, we will use XBlock fields to persist the upstream
|
||||
link, its versions, its customizable fields, and its set of downstream
|
||||
overrides.
|
||||
|
||||
* We will avoid exposing these fields to LMS code.
|
||||
|
||||
* We will define an initial set of customizable fields for Problem, Text, and
|
||||
Video blocks.
|
||||
|
||||
* We will define method(s) for syncing update on the XBlock runtime so that
|
||||
they are available in the SplitModuleStore's XBlock Runtime
|
||||
(CachingDescriptorSystem).
|
||||
|
||||
* Either in the initial implementation or in a later implementation, it may
|
||||
make sense to declare abstract versions of the syncing method(s) higher up
|
||||
in XBlock Runtime inheritance hierarchy.
|
||||
|
||||
* We will expose a CMS HTTP API for syncing updates to blocks from their
|
||||
upstreams.
|
||||
|
||||
* We will avoid exposing this API from the LMS.
|
||||
|
||||
For reference, here are some excerpts of a potential implementation. This may
|
||||
change through development and code review.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
###########################################################################
|
||||
# cms/lib/xblock/upstream_sync.py
|
||||
###########################################################################
|
||||
|
||||
class UpstreamSyncMixin(XBlockMixin):
|
||||
"""
|
||||
Allows an XBlock in the CMS to be associated & synced with an upstream.
|
||||
Mixed into CMS's XBLOCK_MIXINS, but not LMS's.
|
||||
"""
|
||||
|
||||
# Metadata related to upstream synchronization
|
||||
upstream = String(
|
||||
help=("""
|
||||
The usage key of a block (generally within a content library)
|
||||
which serves as a source of upstream updates for this block,
|
||||
or None if there is no such upstream. Please note: It is valid
|
||||
for this field to hold a usage key for an upstream block
|
||||
that does not exist (or does not *yet* exist) on this instance,
|
||||
particularly if this downstream block was imported from a
|
||||
different instance.
|
||||
"""),
|
||||
default=None, scope=Scope.settings, hidden=True, enforce_type=True
|
||||
)
|
||||
upstream_version = Integer(
|
||||
help=("""
|
||||
Record of the upstream block's version number at the time this
|
||||
block was created from it. If upstream_version is smaller
|
||||
than the upstream block's latest version, then the user will be
|
||||
able to sync updates into this downstream block.
|
||||
"""),
|
||||
default=None, scope=Scope.settings, hidden=True, enforce_type=True,
|
||||
)
|
||||
downstream_customized = Set(
|
||||
help=("""
|
||||
Names of the fields which have values set on the upstream
|
||||
block yet have been explicitly overridden on this downstream
|
||||
block. Unless explicitly cleared by the user, these
|
||||
customizations will persist even when updates are synced from
|
||||
the upstream.
|
||||
"""),
|
||||
default=[], scope=Scope.settings, hidden=True, enforce_type=True,
|
||||
)
|
||||
|
||||
# Store upstream defaults for customizable fields.
|
||||
upstream_display_name = String(...)
|
||||
upstream_max_attempts = List(...)
|
||||
... # We will probably want to pre-define several more of these.
|
||||
|
||||
def get_upstream_field_names(cls) -> dict[str, str]:
|
||||
"""
|
||||
Mapping from each customizable field to field which stores its upstream default.
|
||||
XBlocks outside of edx-platform can override this in order to set
|
||||
up their own customizable fields.
|
||||
"""
|
||||
return {
|
||||
"display_name": "upstream_display_name",
|
||||
"max_attempts": "upstream_max_attempts",
|
||||
}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Update `downstream_customized` when a customizable field is modified.
|
||||
Uses `get_upstream_field_names` keys as the list of fields that are
|
||||
customizable.
|
||||
"""
|
||||
...
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpstreamInfo:
|
||||
"""
|
||||
Metadata about a block's relationship with an upstream.
|
||||
"""
|
||||
usage_key: UsageKey
|
||||
current_version: int
|
||||
latest_version: int | None
|
||||
sync_url: str
|
||||
error: str | None
|
||||
|
||||
@property
|
||||
def sync_available(self) -> bool:
|
||||
"""
|
||||
Should the user be prompted to sync this block with upstream?
|
||||
"""
|
||||
return (
|
||||
self.latest_version
|
||||
and self.current_version < self.latest_version
|
||||
and not self.error
|
||||
)
|
||||
|
||||
|
||||
###########################################################################
|
||||
# xmodule/modulestore/split_mongo/caching_descriptor_system.py
|
||||
###########################################################################
|
||||
|
||||
class CachingDescriptorSystem(...):
|
||||
|
||||
def validate_upstream_key(self, usage_key: UsageKey | str) -> UsageKey:
|
||||
"""
|
||||
Raise an error if the provided key is not a valid upstream reference.
|
||||
Instead of explicitly checking whether a key is a LibraryLocatorV2,
|
||||
callers should validate using this function, and use an `except` clause
|
||||
to handle the case where the key is not a valid upstream.
|
||||
Raises: InvalidKeyError, UnsupportedUpstreamKeyType
|
||||
"""
|
||||
...
|
||||
|
||||
def sync_from_upstream(self, *, downstream_key: UsageKey, apply_updates: bool) -> None:
|
||||
"""
|
||||
Python API for loading updates from upstream block.
|
||||
Can choose whether or not to actually apply those updates...
|
||||
apply_updates=False: Think "get fetch".
|
||||
Use case: course import.
|
||||
apply_updates=True: Think "git pull".
|
||||
Use case: sync_updates handler.
|
||||
Raises: InvalidKeyError, UnsupportedUpstreamKeyType, XBlockNotFoundError
|
||||
"""
|
||||
...
|
||||
|
||||
def get_upstream_info(self, downstream_key: UsageKey) -> UpstreamInfo | None:
|
||||
"""
|
||||
Python API for upstream metadata, or None.
|
||||
Raises: InvalidKeyError, XBlockNotFoundError
|
||||
"""
|
||||
...
|
||||
|
||||
Finally, here is what the OLX for a library-sourced Problem XBlock in a course
|
||||
might look like:
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<problem
|
||||
display_name="A title that has been customized in the course"
|
||||
max_attempts="2"
|
||||
upstream="lb:myorg:mylib:problem:p1"
|
||||
upstream_version="12"
|
||||
downstream_customized="["display_name","max_attempts"]"
|
||||
upstream_display_name="The title that was defined in the library block"
|
||||
upstream_max_attempts="3"
|
||||
>
|
||||
<!-- problem content would go here -->
|
||||
</problem>
|
||||
@@ -233,17 +233,29 @@ Content Authoring Events
|
||||
- 2023-07-20
|
||||
|
||||
* - `LIBRARY_BLOCK_CREATED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L167>`_
|
||||
- org.openedx.content_authoring.content_library.created.v1
|
||||
- org.openedx.content_authoring.library_block.created.v1
|
||||
- 2023-07-20
|
||||
|
||||
* - `LIBRARY_BLOCK_UPDATED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L178>`_
|
||||
- org.openedx.content_authoring.content_library.updated.v1
|
||||
- org.openedx.content_authoring.library_block.updated.v1
|
||||
- 2023-07-20
|
||||
|
||||
* - `LIBRARY_BLOCK_DELETED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L189>`_
|
||||
- org.openedx.content_authoring.content_library.deleted.v1
|
||||
- org.openedx.content_authoring.library_block.deleted.v1
|
||||
- 2023-07-20
|
||||
|
||||
* - `CONTENT_OBJECT_TAGS_CHANGED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L207>`_
|
||||
- org.openedx.content_authoring.content.object.tags.changed.v1
|
||||
- 2024-03-31
|
||||
* - `LIBRARY_COLLECTION_CREATED <https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py#L219>`_
|
||||
- org.openedx.content_authoring.content_library.collection.created.v1
|
||||
- 2024-08-23
|
||||
|
||||
* - `LIBRARY_COLLECTION_UPDATED <https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py#L230>`_
|
||||
- org.openedx.content_authoring.content_library.collection.updated.v1
|
||||
- 2024-08-23
|
||||
|
||||
* - `LIBRARY_COLLECTION_DELETED <https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py#L241>`_
|
||||
- org.openedx.content_authoring.content_library.collection.deleted.v1
|
||||
- 2024-08-23
|
||||
|
||||
* - `CONTENT_OBJECT_ASSOCIATIONS_CHANGED <https://github.com/openedx/openedx-events/blob/eb17e03f075b272ad8a29e8435d6a514f8884131/openedx_events/content_authoring/signals.py#L205-L214>`_
|
||||
- org.openedx.content_authoring.content.object.associations.changed.v1
|
||||
- 2024-09-06
|
||||
|
||||
@@ -32,6 +32,8 @@ locations.
|
||||
how-tos/index
|
||||
references/index
|
||||
concepts/index
|
||||
hooks/index
|
||||
extensions/tinymce_plugins
|
||||
|
||||
.. grid:: 1 2 2 2
|
||||
:gutter: 3
|
||||
@@ -74,6 +76,18 @@ locations.
|
||||
:outline:
|
||||
:expand:
|
||||
|
||||
.. grid-item-card:: Hooks and Extensions
|
||||
:class-card: sd-shadow-md sd-p-2
|
||||
:class-footer: sd-border-0
|
||||
|
||||
* :doc:`hooks/index`
|
||||
* :doc:`extensions/tinymce_plugins`
|
||||
+++
|
||||
.. button-ref:: hooks/index
|
||||
:color: primary
|
||||
:outline:
|
||||
:expand:
|
||||
|
||||
|
||||
Change History
|
||||
**************
|
||||
|
||||
@@ -3461,29 +3461,6 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
/demographics/v1/demographics/status/:
|
||||
get:
|
||||
operationId: demographics_v1_demographics_status_list
|
||||
summary: GET /api/user/v1/accounts/demographics/status
|
||||
description: This is a Web API to determine the status of demographics related
|
||||
features
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
tags:
|
||||
- demographics
|
||||
patch:
|
||||
operationId: demographics_v1_demographics_status_partial_update
|
||||
summary: PATCH /api/user/v1/accounts/demographics/status
|
||||
description: This is a Web API to update fields that are dependent on user interaction.
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
tags:
|
||||
- demographics
|
||||
parameters: []
|
||||
/discounts/course/{course_key_string}:
|
||||
get:
|
||||
operationId: discounts_course_read
|
||||
@@ -5300,19 +5277,6 @@ paths:
|
||||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
/entitlements/v1/subscriptions/entitlements/revoke:
|
||||
post:
|
||||
operationId: entitlements_v1_subscriptions_entitlements_revoke_create
|
||||
description: |-
|
||||
Invokes the entitlements expiration process for the provided uuids and downgrades the
|
||||
enrollments to Audit mode.
|
||||
parameters: []
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
tags:
|
||||
- entitlements
|
||||
parameters: []
|
||||
/experiments/v0/custom/REV-934/:
|
||||
get:
|
||||
operationId: experiments_v0_custom_REV-934_list
|
||||
@@ -6649,6 +6613,11 @@ paths:
|
||||
course, chapter, sequential, vertical, html, problem, video, and
|
||||
discussion.
|
||||
display_name: (str) The display name of the block.
|
||||
course_progress: (dict) Contains information about how many assignments are in the course
|
||||
and how many assignments the student has completed.
|
||||
Included here:
|
||||
* total_assignments_count: (int) Total course's assignments count.
|
||||
* assignments_completed: (int) Assignments witch the student has completed.
|
||||
|
||||
**Returns**
|
||||
|
||||
@@ -6696,6 +6665,26 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
/mobile/{api_version}/course_info/{course_id}/enrollment_details:
|
||||
get:
|
||||
operationId: mobile_course_info_enrollment_details_list
|
||||
summary: Handle the GET request
|
||||
description: Returns user enrollment and course details.
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
tags:
|
||||
- mobile
|
||||
parameters:
|
||||
- name: api_version
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
- name: course_id
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
/mobile/{api_version}/course_info/{course_id}/handouts:
|
||||
get:
|
||||
operationId: mobile_course_info_handouts_list
|
||||
@@ -6861,6 +6850,10 @@ paths:
|
||||
An additional attribute "expiration" has been added to the response, which lists the date
|
||||
when access to the course will expire or null if it doesn't expire.
|
||||
|
||||
In v4 we added to the response primary object. Primary object contains the latest user's enrollment
|
||||
or course where user has the latest progress. Primary object has been cut from user's
|
||||
enrolments array and inserted into separated section with key `primary`.
|
||||
|
||||
**Example Request**
|
||||
|
||||
GET /api/mobile/v1/users/{username}/course_enrollments/
|
||||
@@ -6910,14 +6903,14 @@ paths:
|
||||
* mode: The type of certificate registration for this course (honor or
|
||||
certified).
|
||||
* url: URL to the downloadable version of the certificate, if exists.
|
||||
* course_progress: Contains information about how many assignments are in the course
|
||||
and how many assignments the student has completed.
|
||||
* total_assignments_count: Total course's assignments count.
|
||||
* assignments_completed: Assignments witch the student has completed.
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/CourseEnrollment'
|
||||
tags:
|
||||
- mobile
|
||||
parameters:
|
||||
@@ -7031,22 +7024,6 @@ paths:
|
||||
tags:
|
||||
- notifications
|
||||
parameters: []
|
||||
/notifications/channel/configurations/{course_key_string}:
|
||||
patch:
|
||||
operationId: notifications_channel_configurations_partial_update
|
||||
description: Update an existing user notification preference for an entire channel
|
||||
with the data in the request body.
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
tags:
|
||||
- notifications
|
||||
parameters:
|
||||
- name: course_key_string
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
/notifications/configurations/{course_key_string}:
|
||||
get:
|
||||
operationId: notifications_configurations_read
|
||||
@@ -7222,6 +7199,38 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
/notifications/preferences/update/{username}/{patch}/:
|
||||
get:
|
||||
operationId: notifications_preferences_update_read
|
||||
description: |-
|
||||
View to update user preferences from encrypted username and patch.
|
||||
username and patch must be string
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
tags:
|
||||
- notifications
|
||||
post:
|
||||
operationId: notifications_preferences_update_create
|
||||
description: |-
|
||||
View to update user preferences from encrypted username and patch.
|
||||
username and patch must be string
|
||||
parameters: []
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
tags:
|
||||
- notifications
|
||||
parameters:
|
||||
- name: username
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
- name: patch
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
/notifications/read/:
|
||||
patch:
|
||||
operationId: notifications_read_partial_update
|
||||
@@ -11731,39 +11740,6 @@ definitions:
|
||||
title: Course enrollments
|
||||
type: string
|
||||
readOnly: true
|
||||
CourseEnrollment:
|
||||
type: object
|
||||
properties:
|
||||
audit_access_expires:
|
||||
title: Audit access expires
|
||||
type: string
|
||||
readOnly: true
|
||||
created:
|
||||
title: Created
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
x-nullable: true
|
||||
mode:
|
||||
title: Mode
|
||||
type: string
|
||||
maxLength: 100
|
||||
minLength: 1
|
||||
is_active:
|
||||
title: Is active
|
||||
type: boolean
|
||||
course:
|
||||
title: Course
|
||||
type: string
|
||||
readOnly: true
|
||||
certificate:
|
||||
title: Certificate
|
||||
type: string
|
||||
readOnly: true
|
||||
course_modes:
|
||||
title: Course modes
|
||||
type: string
|
||||
readOnly: true
|
||||
Notification:
|
||||
required:
|
||||
- app_name
|
||||
|
||||
@@ -7,3 +7,7 @@ class BulkEmailConfig(AppConfig):
|
||||
Application Configuration for bulk_email.
|
||||
"""
|
||||
name = 'lms.djangoapps.bulk_email'
|
||||
|
||||
def ready(self):
|
||||
import lms.djangoapps.bulk_email.signals # lint-amnesty, pylint: disable=unused-import
|
||||
from edx_ace.signals import ACE_MESSAGE_SENT # lint-amnesty, pylint: disable=unused-import
|
||||
|
||||
@@ -12,3 +12,4 @@ class BulkEmail(BaseMessageType):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.options['from_address'] = kwargs['context']['from_address']
|
||||
self.options['transactional'] = True
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
Signal handlers for the bulk_email app
|
||||
"""
|
||||
|
||||
|
||||
from django.dispatch import receiver
|
||||
from eventtracking import tracker
|
||||
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_MAILINGS
|
||||
from edx_ace.signals import ACE_MESSAGE_SENT
|
||||
|
||||
from .models import Optout
|
||||
|
||||
@@ -24,3 +24,33 @@ def force_optout_all(sender, **kwargs): # lint-amnesty, pylint: disable=unused-
|
||||
|
||||
for enrollment in CourseEnrollment.objects.filter(user=user):
|
||||
Optout.objects.get_or_create(user=user, course_id=enrollment.course.id)
|
||||
|
||||
|
||||
@receiver(ACE_MESSAGE_SENT)
|
||||
def ace_email_sent_handler(sender, **kwargs):
|
||||
"""
|
||||
When an email is sent using ACE, this method will create an event to detect ace email success status
|
||||
"""
|
||||
# Fetch the message dictionary from kwargs, defaulting to {} if not present
|
||||
message = kwargs.get('message', {})
|
||||
recipient = message.get('recipient', {})
|
||||
message_name = message.get('name', None)
|
||||
context = message.get('context', {})
|
||||
email_address = recipient.get('email', None)
|
||||
user_id = recipient.get('user_id', None)
|
||||
channel = message.get('channel', None)
|
||||
course_id = context.get('course_id', None)
|
||||
if not course_id:
|
||||
course_email = context.get('course_email', None)
|
||||
course_id = course_email.course_id if course_email else None
|
||||
|
||||
tracker.emit(
|
||||
'edx.ace.message_sent',
|
||||
{
|
||||
'message_type': message_name,
|
||||
'channel': channel,
|
||||
'course_id': course_id,
|
||||
'user_id': user_id,
|
||||
'user_email': email_address,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import override as override_language
|
||||
from edx_django_utils.monitoring import set_code_owner_attribute
|
||||
from eventtracking import tracker
|
||||
from markupsafe import escape
|
||||
|
||||
from common.djangoapps.util.date_utils import get_default_time_display
|
||||
@@ -456,7 +457,7 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas
|
||||
|
||||
log.info(
|
||||
f"BulkEmail ==> Task: {parent_task_id}, SubTask: {task_id}, EmailId: {email_id}, "
|
||||
f"TotalRecipients: {total_recipients}"
|
||||
f"TotalRecipients: {total_recipients}, ace_enabled: {is_bulk_email_edx_ace_enabled()}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -467,7 +468,15 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas
|
||||
"send."
|
||||
)
|
||||
raise exc
|
||||
|
||||
tracker.emit(
|
||||
'edx.bulk_email.created',
|
||||
{
|
||||
'course_id': str(course_email.course_id),
|
||||
'to_list': [user_obj.get('email', '') for user_obj in to_list],
|
||||
'total_recipients': total_recipients,
|
||||
'ace_enabled_for_bulk_email': is_bulk_email_edx_ace_enabled(),
|
||||
}
|
||||
)
|
||||
# Exclude optouts (if not a retry):
|
||||
# Note that we don't have to do the optout logic at all if this is a retry,
|
||||
# because we have presumably already performed the optout logic on the first
|
||||
@@ -525,7 +534,7 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas
|
||||
email_context['email'] = email
|
||||
email_context['name'] = profile_name
|
||||
email_context['user_id'] = user_id
|
||||
email_context['course_id'] = course_email.course_id
|
||||
email_context['course_id'] = str(course_email.course_id)
|
||||
email_context['unsubscribe_link'] = get_unsubscribed_link(current_recipient['username'],
|
||||
str(course_email.course_id))
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
|
||||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
||||
from django.http import Http404
|
||||
from eventtracking import tracker
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
@@ -60,4 +61,17 @@ def opt_out_email_updates(request, token, course_id):
|
||||
course_id,
|
||||
)
|
||||
|
||||
event_name = 'edx.bulk_email.opt_out'
|
||||
event_data = {
|
||||
"username": user.username,
|
||||
"user_email": user.email,
|
||||
"user_id": user.id,
|
||||
"course_id": course_id,
|
||||
}
|
||||
with tracker.get_tracker().context(event_name, event_data):
|
||||
tracker.emit(
|
||||
event_name,
|
||||
event_data
|
||||
)
|
||||
|
||||
return render_to_response('bulk_email/unsubscribe_success.html', context)
|
||||
|
||||
@@ -730,8 +730,8 @@ class CcxDetailTest(CcxRestApiTest):
|
||||
course_id=ccx_course_key,
|
||||
student_email=self.coach.email,
|
||||
auto_enroll=True,
|
||||
email_students=False,
|
||||
email_params=email_params,
|
||||
message_students=False,
|
||||
message_params=email_params,
|
||||
)
|
||||
return ccx
|
||||
|
||||
|
||||
@@ -505,8 +505,8 @@ class CCXListView(GenericAPIView):
|
||||
course_id=ccx_course_key,
|
||||
student_email=coach.email,
|
||||
auto_enroll=True,
|
||||
email_students=True,
|
||||
email_params=email_params,
|
||||
message_students=True,
|
||||
message_params=email_params,
|
||||
)
|
||||
# assign staff role for the coach to the newly created ccx
|
||||
assign_staff_role_to_ccx(ccx_course_key, coach, master_course_object.id)
|
||||
@@ -768,8 +768,8 @@ class CCXDetailView(GenericAPIView):
|
||||
course_id=ccx_course_key,
|
||||
student_email=coach.email,
|
||||
auto_enroll=True,
|
||||
email_students=True,
|
||||
email_params=email_params,
|
||||
message_students=True,
|
||||
message_params=email_params,
|
||||
)
|
||||
# make the new coach staff on the CCX
|
||||
assign_staff_role_to_ccx(ccx_course_key, coach, master_course_object.id)
|
||||
|
||||
@@ -269,7 +269,13 @@ def ccx_students_enrolling_center(action, identifiers, email_students, course_ke
|
||||
log.info("%s", error)
|
||||
errors.append(error)
|
||||
break
|
||||
enroll_email(course_key, email, auto_enroll=True, email_students=email_students, email_params=email_params)
|
||||
enroll_email(
|
||||
course_key,
|
||||
email,
|
||||
auto_enroll=True,
|
||||
message_students=email_students,
|
||||
message_params=email_params
|
||||
)
|
||||
elif action == 'Unenroll' or action == 'revoke': # lint-amnesty, pylint: disable=consider-using-in
|
||||
for identifier in identifiers:
|
||||
try:
|
||||
@@ -278,7 +284,7 @@ def ccx_students_enrolling_center(action, identifiers, email_students, course_ke
|
||||
log.info("%s", exp)
|
||||
errors.append(f"{exp}")
|
||||
continue
|
||||
unenroll_email(course_key, email, email_students=email_students, email_params=email_params)
|
||||
unenroll_email(course_key, email, message_students=email_students, message_params=email_params)
|
||||
return errors
|
||||
|
||||
|
||||
@@ -348,8 +354,8 @@ def add_master_course_staff_to_ccx(master_course, ccx_key, display_name, send_em
|
||||
course_id=ccx_key,
|
||||
student_email=staff.email,
|
||||
auto_enroll=True,
|
||||
email_students=send_email,
|
||||
email_params=email_params,
|
||||
message_students=send_email,
|
||||
message_params=email_params,
|
||||
)
|
||||
|
||||
# allow 'staff' access on ccx to staff of master course
|
||||
@@ -373,8 +379,8 @@ def add_master_course_staff_to_ccx(master_course, ccx_key, display_name, send_em
|
||||
course_id=ccx_key,
|
||||
student_email=instructor.email,
|
||||
auto_enroll=True,
|
||||
email_students=send_email,
|
||||
email_params=email_params,
|
||||
message_students=send_email,
|
||||
message_params=email_params,
|
||||
)
|
||||
|
||||
# allow 'instructor' access on ccx to instructor of master course
|
||||
@@ -417,8 +423,8 @@ def remove_master_course_staff_from_ccx(master_course, ccx_key, display_name, se
|
||||
unenroll_email(
|
||||
course_id=ccx_key,
|
||||
student_email=staff.email,
|
||||
email_students=send_email,
|
||||
email_params=email_params,
|
||||
message_students=send_email,
|
||||
message_params=email_params,
|
||||
)
|
||||
|
||||
for instructor in list_instructor:
|
||||
@@ -430,6 +436,6 @@ def remove_master_course_staff_from_ccx(master_course, ccx_key, display_name, se
|
||||
unenroll_email(
|
||||
course_id=ccx_key,
|
||||
student_email=instructor.email,
|
||||
email_students=send_email,
|
||||
email_params=email_params,
|
||||
message_students=send_email,
|
||||
message_params=email_params,
|
||||
)
|
||||
|
||||
@@ -223,8 +223,8 @@ def create_ccx(request, course, ccx=None):
|
||||
course_id=ccx_id,
|
||||
student_email=request.user.email,
|
||||
auto_enroll=True,
|
||||
email_students=True,
|
||||
email_params=email_params,
|
||||
message_students=True,
|
||||
message_params=email_params,
|
||||
)
|
||||
|
||||
assign_staff_role_to_ccx(ccx_id, request.user, course.id)
|
||||
|
||||
@@ -10,7 +10,6 @@ certificates models or any other certificates modules.
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import Q
|
||||
@@ -286,12 +285,9 @@ def certificate_downloadable_status(student, course_key):
|
||||
|
||||
course_overview = get_course_overview_or_none(course_key)
|
||||
|
||||
if settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS"):
|
||||
display_behavior_is_valid = (
|
||||
course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.END_WITH_DATE
|
||||
)
|
||||
else:
|
||||
display_behavior_is_valid = True
|
||||
display_behavior_is_valid = (
|
||||
course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.END_WITH_DATE
|
||||
)
|
||||
|
||||
if (
|
||||
not certificates_viewable_for_course(course_overview)
|
||||
@@ -837,10 +833,7 @@ def can_show_certificate_message(course, student, course_grade, certificates_ena
|
||||
|
||||
def _course_uses_available_date(course):
|
||||
"""Returns if the course has an certificate_available_date set and that it should be used"""
|
||||
if settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS"):
|
||||
display_behavior_is_valid = course.certificates_display_behavior == CertificatesDisplayBehaviors.END_WITH_DATE
|
||||
else:
|
||||
display_behavior_is_valid = True
|
||||
display_behavior_is_valid = course.certificates_display_behavior == CertificatesDisplayBehaviors.END_WITH_DATE
|
||||
|
||||
return (
|
||||
can_show_certificate_available_date_field(course)
|
||||
|
||||
@@ -31,7 +31,7 @@ workspace {
|
||||
}
|
||||
|
||||
grades_app -> signal_handlers "Emits COURSE_GRADE_NOW_PASSED signal"
|
||||
verify_student_app -> signal_handlers "Emits LEARNER_NOW_VERIFIED signal"
|
||||
verify_student_app -> signal_handlers "Emits IDV_ATTEMPT_APPROVED signal"
|
||||
student_app -> signal_handlers "Emits ENROLLMENT_TRACK_UPDATED signal"
|
||||
allowlist -> signal_handlers "Emits APPEND_CERTIFICATE_ALLOWLIST signal"
|
||||
signal_handlers -> generation_handler "Invokes generate_allowlist_certificate()"
|
||||
|
||||
@@ -32,9 +32,8 @@ from openedx.core.djangoapps.content.course_overviews.signals import COURSE_PACI
|
||||
from openedx.core.djangoapps.signals.signals import (
|
||||
COURSE_GRADE_NOW_FAILED,
|
||||
COURSE_GRADE_NOW_PASSED,
|
||||
LEARNER_NOW_VERIFIED
|
||||
)
|
||||
from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED
|
||||
from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED, IDV_ATTEMPT_APPROVED
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -118,14 +117,17 @@ def _listen_for_failing_grade(sender, user, course_id, grade, **kwargs): # pyli
|
||||
log.info(f'Certificate marked not passing for {user.id} : {course_id} via failing grade')
|
||||
|
||||
|
||||
@receiver(LEARNER_NOW_VERIFIED, dispatch_uid="learner_track_changed")
|
||||
def _listen_for_id_verification_status_changed(sender, user, **kwargs): # pylint: disable=unused-argument
|
||||
@receiver(IDV_ATTEMPT_APPROVED, dispatch_uid="learner_track_changed")
|
||||
def _listen_for_id_verification_status_changed(sender, signal, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Listen for a signal indicating that the user's id verification status has changed.
|
||||
"""
|
||||
if not auto_certificate_generation_enabled():
|
||||
return
|
||||
|
||||
event_data = kwargs.get('idv_attempt')
|
||||
user = User.objects.get(id=event_data.user.id)
|
||||
|
||||
user_enrollments = CourseEnrollment.enrollments_for_user(user=user)
|
||||
expected_verification_status = IDVerificationService.user_status(user)
|
||||
expected_verification_status = expected_verification_status['status']
|
||||
|
||||
@@ -209,29 +209,6 @@ class CertificateDownloadableStatusTests(WebCertificateTestMixin, ModuleStoreTes
|
||||
"uuid": cert_status["uuid"],
|
||||
}
|
||||
|
||||
@ddt.data(
|
||||
(False, timedelta(days=2), False, True),
|
||||
(False, -timedelta(days=2), True, None),
|
||||
(True, timedelta(days=2), True, None),
|
||||
)
|
||||
@ddt.unpack
|
||||
@patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True})
|
||||
@patch.dict(settings.FEATURES, {"ENABLE_V2_CERT_DISPLAY_SETTINGS": False})
|
||||
def test_cert_api_return_v1(self, self_paced, cert_avail_delta, cert_downloadable_status, earned_but_not_available):
|
||||
"""
|
||||
Test 'downloadable status'
|
||||
"""
|
||||
cert_avail_date = datetime.now(pytz.UTC) + cert_avail_delta
|
||||
self.course.self_paced = self_paced
|
||||
self.course.certificate_available_date = cert_avail_date
|
||||
self.course.save()
|
||||
|
||||
self._setup_course_certificate()
|
||||
|
||||
downloadable_status = certificate_downloadable_status(self.student, self.course.id)
|
||||
assert downloadable_status["is_downloadable"] == cert_downloadable_status
|
||||
assert downloadable_status.get("earned_but_not_available") == earned_but_not_available
|
||||
|
||||
@ddt.data(
|
||||
(True, timedelta(days=2), CertificatesDisplayBehaviors.END_WITH_DATE, True, None),
|
||||
(False, -timedelta(days=2), CertificatesDisplayBehaviors.EARLY_NO_INFO, True, None),
|
||||
@@ -243,8 +220,7 @@ class CertificateDownloadableStatusTests(WebCertificateTestMixin, ModuleStoreTes
|
||||
)
|
||||
@ddt.unpack
|
||||
@patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True})
|
||||
@patch.dict(settings.FEATURES, {"ENABLE_V2_CERT_DISPLAY_SETTINGS": True})
|
||||
def test_cert_api_return_v2(
|
||||
def test_cert_api_return(
|
||||
self,
|
||||
self_paced,
|
||||
cert_avail_delta,
|
||||
|
||||
@@ -13,22 +13,20 @@ from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from openedx_events.data import EventsMetadata
|
||||
from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData
|
||||
from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from openedx_events.tests.utils import OpenEdxEventsTestMixin
|
||||
|
||||
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from lms.djangoapps.certificates.api import has_self_generated_certificates_enabled
|
||||
from lms.djangoapps.certificates.config import AUTO_CERTIFICATE_GENERATION
|
||||
from lms.djangoapps.certificates.data import CertificateStatuses
|
||||
from lms.djangoapps.certificates.models import (
|
||||
CertificateGenerationConfiguration,
|
||||
GeneratedCertificate
|
||||
)
|
||||
from lms.djangoapps.certificates.models import CertificateGenerationConfiguration, GeneratedCertificate
|
||||
from lms.djangoapps.certificates.signals import handle_exam_attempt_rejected_event
|
||||
from lms.djangoapps.certificates.tests.factories import CertificateAllowlistFactory, GeneratedCertificateFactory
|
||||
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
|
||||
from lms.djangoapps.grades.tests.utils import mock_passing_grade
|
||||
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
class SelfGeneratedCertsSignalTest(ModuleStoreTestCase):
|
||||
@@ -302,10 +300,17 @@ class FailingGradeCertsTest(ModuleStoreTestCase):
|
||||
assert cert.status == CertificateStatuses.downloadable
|
||||
|
||||
|
||||
class LearnerIdVerificationTest(ModuleStoreTestCase):
|
||||
class LearnerIdVerificationTest(ModuleStoreTestCase, OpenEdxEventsTestMixin):
|
||||
"""
|
||||
Tests for certificate generation task firing on learner id verification
|
||||
"""
|
||||
ENABLED_OPENEDX_EVENTS = ['org.openedx.learning.idv_attempt.approved.v1']
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.start_events_isolation()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.course_one = CourseFactory.create(self_paced=True)
|
||||
|
||||
@@ -5,7 +5,6 @@ from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from pytz import utc
|
||||
|
||||
@@ -80,40 +79,7 @@ class CertificateUtilityTests(TestCase):
|
||||
(CertificatesDisplayBehaviors.END, False, False, _LAST_MONTH, True, True),
|
||||
)
|
||||
@ddt.unpack
|
||||
@patch.dict(settings.FEATURES, ENABLE_V2_CERT_DISPLAY_SETTINGS=True)
|
||||
def test_should_certificate_be_visible_v2(
|
||||
self,
|
||||
certificates_display_behavior,
|
||||
certificates_show_before_end,
|
||||
has_ended,
|
||||
certificate_available_date,
|
||||
self_paced,
|
||||
expected_value
|
||||
):
|
||||
"""Test whether the certificate should be visible to user given multiple usecases"""
|
||||
assert should_certificate_be_visible(
|
||||
certificates_display_behavior,
|
||||
certificates_show_before_end,
|
||||
has_ended,
|
||||
certificate_available_date,
|
||||
self_paced
|
||||
) == expected_value
|
||||
|
||||
@ddt.data(
|
||||
('early_with_info', True, True, _LAST_MONTH, False, True),
|
||||
('early_no_info', False, False, _LAST_MONTH, False, True),
|
||||
('end', True, False, _LAST_MONTH, False, True),
|
||||
('end', False, True, _LAST_MONTH, False, True),
|
||||
('end', False, False, _NEXT_WEEK, False, False),
|
||||
('end', False, False, _LAST_WEEK, False, True),
|
||||
('end', False, False, None, False, False),
|
||||
('early_with_info', False, False, None, False, True),
|
||||
('end', False, False, _NEXT_WEEK, False, False),
|
||||
('end', False, False, _NEXT_WEEK, True, True),
|
||||
)
|
||||
@ddt.unpack
|
||||
@patch.dict(settings.FEATURES, ENABLE_V2_CERT_DISPLAY_SETTINGS=False)
|
||||
def test_should_certificate_be_visible_v1(
|
||||
def test_should_certificate_be_visible(
|
||||
self,
|
||||
certificates_display_behavior,
|
||||
certificates_show_before_end,
|
||||
|
||||
@@ -153,30 +153,19 @@ def should_certificate_be_visible(
|
||||
certificate_available_date (datetime): the date the certificate is available on for the course.
|
||||
self_paced (bool): Whether the course is self-paced.
|
||||
"""
|
||||
if settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS"):
|
||||
show_early = (
|
||||
certificates_display_behavior == CertificatesDisplayBehaviors.EARLY_NO_INFO
|
||||
or certificates_show_before_end
|
||||
)
|
||||
past_available_date = (
|
||||
certificates_display_behavior == CertificatesDisplayBehaviors.END_WITH_DATE
|
||||
and certificate_available_date
|
||||
and certificate_available_date < datetime.now(utc)
|
||||
)
|
||||
ended_without_available_date = (
|
||||
certificates_display_behavior == CertificatesDisplayBehaviors.END
|
||||
and has_ended
|
||||
)
|
||||
else:
|
||||
show_early = (
|
||||
certificates_display_behavior in ('early_with_info', 'early_no_info')
|
||||
or certificates_show_before_end
|
||||
)
|
||||
past_available_date = (
|
||||
certificate_available_date
|
||||
and certificate_available_date < datetime.now(utc)
|
||||
)
|
||||
ended_without_available_date = (certificate_available_date is None) and has_ended
|
||||
show_early = (
|
||||
certificates_display_behavior == CertificatesDisplayBehaviors.EARLY_NO_INFO
|
||||
or certificates_show_before_end
|
||||
)
|
||||
past_available_date = (
|
||||
certificates_display_behavior == CertificatesDisplayBehaviors.END_WITH_DATE
|
||||
and certificate_available_date
|
||||
and certificate_available_date < datetime.now(utc)
|
||||
)
|
||||
ended_without_available_date = (
|
||||
certificates_display_behavior == CertificatesDisplayBehaviors.END
|
||||
and has_ended
|
||||
)
|
||||
|
||||
return any((self_paced, show_early, past_available_date, ended_without_available_date))
|
||||
|
||||
|
||||
@@ -353,28 +353,22 @@ def _get_user_certificate(request, user, course_key, course_overview, preview_mo
|
||||
if preview_mode:
|
||||
# certificate is being previewed from studio
|
||||
if request.user.has_perm(PREVIEW_CERTIFICATES, course_overview):
|
||||
if not settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS"):
|
||||
if course_overview.certificate_available_date and not course_overview.self_paced:
|
||||
modified_date = course_overview.certificate_available_date
|
||||
else:
|
||||
modified_date = datetime.now().date()
|
||||
if (
|
||||
course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.END_WITH_DATE
|
||||
and course_overview.certificate_available_date
|
||||
and not course_overview.self_paced
|
||||
):
|
||||
modified_date = course_overview.certificate_available_date
|
||||
elif course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.END:
|
||||
modified_date = course_overview.end
|
||||
else:
|
||||
if (
|
||||
course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.END_WITH_DATE
|
||||
and course_overview.certificate_available_date
|
||||
and not course_overview.self_paced
|
||||
):
|
||||
modified_date = course_overview.certificate_available_date
|
||||
elif course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.END:
|
||||
modified_date = course_overview.end
|
||||
else:
|
||||
modified_date = datetime.now().date()
|
||||
user_certificate = GeneratedCertificate(
|
||||
mode=preview_mode,
|
||||
verify_uuid=str(uuid4().hex),
|
||||
modified_date=modified_date,
|
||||
created_date=datetime.now().date(),
|
||||
)
|
||||
modified_date = datetime.now().date()
|
||||
user_certificate = GeneratedCertificate(
|
||||
mode=preview_mode,
|
||||
verify_uuid=str(uuid4().hex),
|
||||
modified_date=modified_date,
|
||||
created_date=datetime.now().date(),
|
||||
)
|
||||
elif certificates_viewable_for_course(course_overview):
|
||||
# certificate is being viewed by learner or public
|
||||
try:
|
||||
|
||||
@@ -27,6 +27,7 @@ from .waffle import ( # lint-amnesty, pylint: disable=invalid-django-waffle-imp
|
||||
should_redirect_to_commerce_coordinator_checkout,
|
||||
should_redirect_to_commerce_coordinator_refunds,
|
||||
)
|
||||
from edx_django_utils.plugins import pluggable_override
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -56,6 +57,7 @@ class EcommerceService:
|
||||
""" Retrieve Ecommerce service public url root. """
|
||||
return configuration_helpers.get_value('ECOMMERCE_PUBLIC_URL_ROOT', settings.ECOMMERCE_PUBLIC_URL_ROOT)
|
||||
|
||||
@pluggable_override('OVERRIDE_GET_ABSOLUTE_ECOMMERCE_URL')
|
||||
def get_absolute_ecommerce_url(self, ecommerce_page_url):
|
||||
""" Return the absolute URL to the ecommerce page.
|
||||
|
||||
@@ -110,7 +112,6 @@ class EcommerceService:
|
||||
|
||||
def get_add_to_basket_url(self):
|
||||
""" Return the URL for the payment page based on the waffle switch.
|
||||
|
||||
Example:
|
||||
http://localhost/enabled_service_api_path
|
||||
"""
|
||||
@@ -118,12 +119,14 @@ class EcommerceService:
|
||||
return urljoin(settings.COMMERCE_COORDINATOR_URL_ROOT, settings.COORDINATOR_CHECKOUT_REDIRECT_PATH)
|
||||
return self.payment_page_url()
|
||||
|
||||
@pluggable_override('OVERRIDE_GET_CHECKOUT_PAGE_URL')
|
||||
def get_checkout_page_url(self, *skus, **kwargs):
|
||||
""" Construct the URL to the ecommerce checkout page and include products.
|
||||
|
||||
Args:
|
||||
skus (list): List of SKUs associated with products to be added to basket
|
||||
program_uuid (string): The UUID of the program, if applicable
|
||||
course_run_keys (list): The course run keys of the products to be added to basket.
|
||||
|
||||
Returns:
|
||||
Absolute path to the ecommerce checkout page showing basket that contains specified products.
|
||||
@@ -153,10 +156,12 @@ class EcommerceService:
|
||||
"""
|
||||
Returns the URL for the user to upgrade, or None if not applicable.
|
||||
"""
|
||||
course_run_key = str(course_key)
|
||||
|
||||
verified_mode = CourseMode.verified_mode_for_course(course_key)
|
||||
if verified_mode:
|
||||
if self.is_enabled(user):
|
||||
return self.get_checkout_page_url(verified_mode.sku)
|
||||
return self.get_checkout_page_url(verified_mode.sku, course_run_keys=[course_run_key])
|
||||
else:
|
||||
return reverse('dashboard')
|
||||
return None
|
||||
|
||||
@@ -75,6 +75,7 @@ def send_ace_message(goal):
|
||||
'email': user.email,
|
||||
'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
|
||||
'course_name': course_name,
|
||||
'course_id': str(goal.course_key),
|
||||
'days_per_week': goal.days_per_week,
|
||||
'course_url': course_home_url,
|
||||
'goals_unsubscribe_url': goals_unsubscribe_url,
|
||||
|
||||
@@ -43,6 +43,7 @@ class CourseHomeMetadataSerializer(VerifiedModeSerializer):
|
||||
"""
|
||||
celebrations = serializers.DictField()
|
||||
course_access = serializers.DictField()
|
||||
studio_access = serializers.BooleanField()
|
||||
course_id = serializers.CharField()
|
||||
is_enrolled = serializers.BooleanField()
|
||||
is_self_paced = serializers.BooleanField()
|
||||
|
||||
@@ -20,7 +20,7 @@ from common.djangoapps.student.models import CourseEnrollment
|
||||
from lms.djangoapps.course_api.api import course_detail
|
||||
from lms.djangoapps.course_goals.models import UserActivity
|
||||
from lms.djangoapps.course_home_api.course_metadata.serializers import CourseHomeMetadataSerializer
|
||||
from lms.djangoapps.courseware.access import has_access
|
||||
from lms.djangoapps.courseware.access import has_access, has_cms_access
|
||||
from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs
|
||||
from lms.djangoapps.courseware.courses import check_course_access
|
||||
from lms.djangoapps.courseware.masquerade import setup_masquerade
|
||||
@@ -124,6 +124,7 @@ class CourseHomeMetadataView(RetrieveAPIView):
|
||||
data = {
|
||||
'course_id': course.id,
|
||||
'username': username,
|
||||
'studio_access': has_cms_access(request.user, course_key),
|
||||
'is_staff': has_access(request.user, 'staff', course_key).has_access,
|
||||
'original_user_is_staff': original_user_is_staff,
|
||||
'number': course.display_number_with_default,
|
||||
|
||||
@@ -53,7 +53,8 @@ from common.djangoapps.student.roles import (
|
||||
GlobalStaff,
|
||||
OrgInstructorRole,
|
||||
OrgStaffRole,
|
||||
SupportStaffRole
|
||||
SupportStaffRole,
|
||||
CourseLimitedStaffRole,
|
||||
)
|
||||
from common.djangoapps.util import milestones_helpers as milestones_helpers # lint-amnesty, pylint: disable=useless-import-alias
|
||||
from common.djangoapps.util.milestones_helpers import (
|
||||
@@ -97,6 +98,31 @@ def has_ccx_coach_role(user, course_key):
|
||||
return False
|
||||
|
||||
|
||||
def has_cms_access(user, course_key):
|
||||
"""
|
||||
Check if user has access to the CMS. When requesting from the LMS, a user with the
|
||||
limited staff access role needs access to the CMS APIs, but not the CMS site. This
|
||||
function accounts for this edge case when determining if a user has access to the CMS
|
||||
site.
|
||||
|
||||
Arguments:
|
||||
user (User): the user whose course access we are checking.
|
||||
course_key: Key to course.
|
||||
|
||||
Returns:
|
||||
bool: whether user has access to the CMS site.
|
||||
"""
|
||||
has_course_author_access = auth.has_course_author_access(user, course_key)
|
||||
is_limited_staff = auth.user_has_role(
|
||||
user, CourseLimitedStaffRole(course_key)
|
||||
) and not GlobalStaff().has_user(user)
|
||||
|
||||
if is_limited_staff and has_course_author_access:
|
||||
return False
|
||||
|
||||
return has_course_author_access
|
||||
|
||||
|
||||
@function_trace('has_access')
|
||||
def has_access(user, action, obj, course_key=None):
|
||||
"""
|
||||
|
||||
@@ -264,12 +264,12 @@ class CustomPagesCourseApp(CourseApp):
|
||||
|
||||
class ORASettingsApp(CourseApp):
|
||||
"""
|
||||
Course App config for ORA app.
|
||||
Course App config for Flexible Peer Grading ORA app.
|
||||
"""
|
||||
|
||||
app_id = "ora_settings"
|
||||
name = _("Open Response Assessment Settings")
|
||||
description = _("Course level settings for Open Response Assessment.")
|
||||
name = _("Flexible Peer Grading for ORAs")
|
||||
description = _("Course level settings for Flexible Peer Grading Open Response Assessments.")
|
||||
documentation_links = {
|
||||
"learn_more_configuration": settings.ORA_SETTINGS_HELP_URL,
|
||||
}
|
||||
@@ -287,14 +287,15 @@ class ORASettingsApp(CourseApp):
|
||||
"""
|
||||
Get open response enabled status from course overview model.
|
||||
"""
|
||||
return True
|
||||
course = get_course_by_id(course_key)
|
||||
return course.force_on_flexible_peer_openassessments
|
||||
|
||||
@classmethod
|
||||
def set_enabled(cls, course_key: CourseKey, enabled: bool, user: 'User') -> bool:
|
||||
"""
|
||||
Update open response enabled status in modulestore. Always enable to avoid confusion that user can disable ora.
|
||||
"""
|
||||
return True
|
||||
raise ValueError("Flexible Peer Grading cannot be enabled/disabled via this API.")
|
||||
|
||||
@classmethod
|
||||
def get_allowed_operations(cls, course_key: CourseKey, user: Optional[User] = None) -> Dict[str, bool]:
|
||||
|
||||
@@ -156,7 +156,10 @@ class AboutTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase, EventTra
|
||||
assert resp.status_code == 200
|
||||
pre_requisite_courses = get_prerequisite_courses_display(course)
|
||||
pre_requisite_course_about_url = reverse('about_course', args=[str(pre_requisite_courses[0]['key'])])
|
||||
assert '<span class="important-dates-item-text pre-requisite"><a href="{}">{}</a></span>'.format(pre_requisite_course_about_url, pre_requisite_courses[0]['display']) in resp.content.decode(resp.charset).strip('\n') # pylint: disable=line-too-long
|
||||
assert (
|
||||
f'You must successfully complete <a href="{pre_requisite_course_about_url}">'
|
||||
f'{pre_requisite_courses[0]["display"]}</a> before you begin this course.'
|
||||
) in resp.content.decode(resp.charset).strip('\n')
|
||||
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True})
|
||||
def test_about_page_unfulfilled_prereqs(self):
|
||||
@@ -190,7 +193,10 @@ class AboutTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase, EventTra
|
||||
assert resp.status_code == 200
|
||||
pre_requisite_courses = get_prerequisite_courses_display(course)
|
||||
pre_requisite_course_about_url = reverse('about_course', args=[str(pre_requisite_courses[0]['key'])])
|
||||
assert '<span class="important-dates-item-text pre-requisite"><a href="{}">{}</a></span>'.format(pre_requisite_course_about_url, pre_requisite_courses[0]['display']) in resp.content.decode(resp.charset).strip('\n') # pylint: disable=line-too-long
|
||||
assert (
|
||||
f'You must successfully complete <a href="{pre_requisite_course_about_url}">'
|
||||
f'{pre_requisite_courses[0]["display"]}</a> before you begin this course.'
|
||||
) in resp.content.decode(resp.charset).strip('\n')
|
||||
|
||||
url = reverse('about_course', args=[str(pre_requisite_course.id)])
|
||||
resp = self.client.get(url)
|
||||
|
||||
@@ -825,9 +825,13 @@ def course_about(request, course_id): # pylint: disable=too-many-statements
|
||||
single_paid_mode = modes.get(CourseMode.PROFESSIONAL)
|
||||
|
||||
if single_paid_mode and single_paid_mode.sku:
|
||||
ecommerce_checkout_link = ecomm_service.get_checkout_page_url(single_paid_mode.sku)
|
||||
ecommerce_checkout_link = ecomm_service.get_checkout_page_url(
|
||||
single_paid_mode.sku, course_run_keys=[course_id]
|
||||
)
|
||||
if single_paid_mode and single_paid_mode.bulk_sku:
|
||||
ecommerce_bulk_checkout_link = ecomm_service.get_checkout_page_url(single_paid_mode.bulk_sku)
|
||||
ecommerce_bulk_checkout_link = ecomm_service.get_checkout_page_url(
|
||||
single_paid_mode.bulk_sku, course_run_keys=[course_id]
|
||||
)
|
||||
|
||||
registration_price, course_price = get_course_prices(course) # lint-amnesty, pylint: disable=unused-variable
|
||||
|
||||
|
||||
@@ -1458,6 +1458,8 @@ class CreateSubCommentUnicodeTestCase(
|
||||
@disable_signal(views, 'comment_created')
|
||||
@disable_signal(views, 'comment_voted')
|
||||
@disable_signal(views, 'comment_deleted')
|
||||
@disable_signal(views, 'comment_flagged')
|
||||
@disable_signal(views, 'thread_flagged')
|
||||
class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase, MockRequestSetupMixin):
|
||||
# Most of the test points use the same ddt data.
|
||||
# args: user, commentable_id, status_code
|
||||
|
||||
@@ -3,7 +3,10 @@ Discussion notifications sender util.
|
||||
"""
|
||||
import re
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from django.conf import settings
|
||||
from django.utils.text import Truncator
|
||||
|
||||
from lms.djangoapps.discussion.django_comment_client.permissions import get_team
|
||||
from openedx_events.learning.data import UserNotificationData, CourseNotificationData
|
||||
from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED, COURSE_NOTIFICATION_REQUESTED
|
||||
@@ -27,13 +30,24 @@ class DiscussionNotificationSender:
|
||||
Class to send notifications to users who are subscribed to the thread.
|
||||
"""
|
||||
|
||||
def __init__(self, thread, course, creator, parent_id=None):
|
||||
def __init__(self, thread, course, creator, parent_id=None, comment_id=None):
|
||||
self.thread = thread
|
||||
self.course = course
|
||||
self.creator = creator
|
||||
self.parent_id = parent_id
|
||||
self.comment_id = comment_id
|
||||
self.parent_response = None
|
||||
self.comment = None
|
||||
self._get_parent_response()
|
||||
self._get_comment()
|
||||
|
||||
def _get_comment(self):
|
||||
"""
|
||||
Get comment object
|
||||
"""
|
||||
if not self.comment_id:
|
||||
return
|
||||
self.comment = Comment(id=self.comment_id).retrieve()
|
||||
|
||||
def _send_notification(self, user_ids, notification_type, extra_context=None):
|
||||
"""
|
||||
@@ -99,7 +113,10 @@ class DiscussionNotificationSender:
|
||||
there is a response to the main thread.
|
||||
"""
|
||||
if not self.parent_id and self.creator.id != int(self.thread.user_id):
|
||||
self._send_notification([self.thread.user_id], "new_response")
|
||||
context = {
|
||||
'email_content': clean_thread_html_body(self.comment.body),
|
||||
}
|
||||
self._send_notification([self.thread.user_id], "new_response", extra_context=context)
|
||||
|
||||
def _response_and_thread_has_same_creator(self) -> bool:
|
||||
"""
|
||||
@@ -118,9 +135,10 @@ class DiscussionNotificationSender:
|
||||
self.parent_response and
|
||||
self.creator.id != int(self.thread.user_id)
|
||||
):
|
||||
author_name = f"{self.parent_response.username}'s"
|
||||
# use your if author of response is same as author of post.
|
||||
# use 'their' if comment author is also response author.
|
||||
author_name = (
|
||||
author_pronoun = (
|
||||
# Translators: Replier commented on "your" response to your post
|
||||
_("your")
|
||||
if self._response_and_thread_has_same_creator()
|
||||
@@ -129,10 +147,14 @@ class DiscussionNotificationSender:
|
||||
_("their")
|
||||
if self._response_and_comment_has_same_creator()
|
||||
else f"{self.parent_response.username}'s"
|
||||
|
||||
)
|
||||
)
|
||||
context = {
|
||||
"author_name": str(author_name),
|
||||
"author_pronoun": str(author_pronoun),
|
||||
"email_content": clean_thread_html_body(self.comment.body),
|
||||
"group_by_id": self.parent_response.id
|
||||
}
|
||||
self._send_notification([self.thread.user_id], "new_comment", extra_context=context)
|
||||
|
||||
@@ -146,7 +168,14 @@ class DiscussionNotificationSender:
|
||||
self.creator.id != int(self.parent_response.user_id) and not
|
||||
self._response_and_thread_has_same_creator()
|
||||
):
|
||||
self._send_notification([self.parent_response.user_id], "new_comment_on_response")
|
||||
context = {
|
||||
"email_content": clean_thread_html_body(self.comment.body),
|
||||
}
|
||||
self._send_notification(
|
||||
[self.parent_response.user_id],
|
||||
"new_comment_on_response",
|
||||
extra_context=context
|
||||
)
|
||||
|
||||
def _check_if_subscriber_is_not_thread_or_content_creator(self, subscriber_id) -> bool:
|
||||
"""
|
||||
@@ -187,12 +216,29 @@ class DiscussionNotificationSender:
|
||||
# Remove duplicate users from the list of users to send notification
|
||||
users = list(set(users))
|
||||
if not self.parent_id:
|
||||
self._send_notification(users, "response_on_followed_post")
|
||||
self._send_notification(
|
||||
users,
|
||||
"response_on_followed_post",
|
||||
extra_context={
|
||||
"email_content": clean_thread_html_body(self.comment.body),
|
||||
})
|
||||
else:
|
||||
author_name = f"{self.parent_response.username}'s"
|
||||
# use 'their' if comment author is also response author.
|
||||
author_pronoun = (
|
||||
# Translators: Replier commented on "their" response in a post you're following
|
||||
_("their")
|
||||
if self._response_and_comment_has_same_creator()
|
||||
else f"{self.parent_response.username}'s"
|
||||
)
|
||||
self._send_notification(
|
||||
users,
|
||||
"comment_on_followed_post",
|
||||
extra_context={"author_name": self.parent_response.username}
|
||||
extra_context={
|
||||
"author_name": str(author_name),
|
||||
"author_pronoun": str(author_pronoun),
|
||||
"email_content": clean_thread_html_body(self.comment.body),
|
||||
}
|
||||
)
|
||||
|
||||
def _create_cohort_course_audience(self):
|
||||
@@ -241,13 +287,19 @@ class DiscussionNotificationSender:
|
||||
response on his thread has been endorsed
|
||||
"""
|
||||
if self.creator.id != int(self.thread.user_id):
|
||||
self._send_notification([self.thread.user_id], "response_endorsed_on_thread")
|
||||
context = {
|
||||
"email_content": clean_thread_html_body(self.comment.body)
|
||||
}
|
||||
self._send_notification([self.thread.user_id], "response_endorsed_on_thread", extra_context=context)
|
||||
|
||||
def send_response_endorsed_notification(self):
|
||||
"""
|
||||
Sends a notification to the author of the response
|
||||
"""
|
||||
self._send_notification([self.creator.id], "response_endorsed")
|
||||
context = {
|
||||
"email_content": clean_thread_html_body(self.comment.body)
|
||||
}
|
||||
self._send_notification([self.creator.id], "response_endorsed", extra_context=context)
|
||||
|
||||
def send_new_thread_created_notification(self):
|
||||
"""
|
||||
@@ -275,7 +327,8 @@ class DiscussionNotificationSender:
|
||||
]
|
||||
context = {
|
||||
'username': self.creator.username,
|
||||
'post_title': self.thread.title
|
||||
'post_title': self.thread.title,
|
||||
"email_content": clean_thread_html_body(self.thread.body),
|
||||
}
|
||||
self._send_course_wide_notification(notification_type, audience_filters, context)
|
||||
|
||||
@@ -300,7 +353,7 @@ class DiscussionNotificationSender:
|
||||
content_type = thread_types[self.thread.type][getattr(self.thread, 'depth', 0)]
|
||||
|
||||
context = {
|
||||
'username': self.creator.username,
|
||||
'username': self.thread.username,
|
||||
'content_type': content_type,
|
||||
'content': thread_body
|
||||
}
|
||||
@@ -325,3 +378,40 @@ def is_discussion_cohorted(course_key_str):
|
||||
def remove_html_tags(text):
|
||||
clean = re.compile('<.*?>')
|
||||
return re.sub(clean, '', text)
|
||||
|
||||
|
||||
def clean_thread_html_body(html_body):
|
||||
"""
|
||||
Get post body with tags removed and limited to 500 characters
|
||||
"""
|
||||
html_body = BeautifulSoup(Truncator(html_body).chars(500, html=True), 'html.parser')
|
||||
|
||||
tags_to_remove = [
|
||||
"a", "link", # Link Tags
|
||||
"img", "picture", "source", # Image Tags
|
||||
"video", "track", # Video Tags
|
||||
"audio", # Audio Tags
|
||||
"embed", "object", "iframe", # Embedded Content
|
||||
"script"
|
||||
]
|
||||
|
||||
# Remove the specified tags while keeping their content
|
||||
for tag in tags_to_remove:
|
||||
for match in html_body.find_all(tag):
|
||||
match.unwrap()
|
||||
|
||||
# Replace tags that are not allowed in email
|
||||
tags_to_update = [
|
||||
{"source": "button", "target": "span"},
|
||||
{"source": "h1", "target": "h4"},
|
||||
{"source": "h2", "target": "h4"},
|
||||
{"source": "h3", "target": "h4"},
|
||||
]
|
||||
for tag_dict in tags_to_update:
|
||||
for source_tag in html_body.find_all(tag_dict['source']):
|
||||
target_tag = html_body.new_tag(tag_dict['target'], **source_tag.attrs)
|
||||
if source_tag.string:
|
||||
target_tag.string = source_tag.string
|
||||
source_tag.replace_with(target_tag)
|
||||
|
||||
return str(html_body)
|
||||
|
||||
@@ -33,7 +33,7 @@ def send_thread_created_notification(thread_id, course_key_str, user_id):
|
||||
|
||||
@shared_task
|
||||
@set_code_owner_attribute
|
||||
def send_response_notifications(thread_id, course_key_str, user_id, parent_id=None):
|
||||
def send_response_notifications(thread_id, course_key_str, user_id, comment_id, parent_id=None):
|
||||
"""
|
||||
Send notifications to users who are subscribed to the thread.
|
||||
"""
|
||||
@@ -43,7 +43,7 @@ def send_response_notifications(thread_id, course_key_str, user_id, parent_id=No
|
||||
thread = Thread(id=thread_id).retrieve()
|
||||
user = User.objects.get(id=user_id)
|
||||
course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True)
|
||||
notification_sender = DiscussionNotificationSender(thread, course, user, parent_id)
|
||||
notification_sender = DiscussionNotificationSender(thread, course, user, parent_id, comment_id)
|
||||
notification_sender.send_new_comment_notification()
|
||||
notification_sender.send_new_response_notification()
|
||||
notification_sender.send_new_comment_on_response_notification()
|
||||
@@ -64,7 +64,7 @@ def send_response_endorsed_notifications(thread_id, response_id, course_key_str,
|
||||
creator = User.objects.get(id=response.user_id)
|
||||
endorser = User.objects.get(id=endorsed_by)
|
||||
course = get_course_with_access(creator, 'load', course_key, check_if_enrolled=True)
|
||||
notification_sender = DiscussionNotificationSender(thread, course, creator)
|
||||
notification_sender = DiscussionNotificationSender(thread, course, creator, comment_id=response_id)
|
||||
# skip sending notification to author of thread if they are the same as the author of the response
|
||||
if response.user_id != thread.user_id:
|
||||
# sends notification to author of thread
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""
|
||||
Unit tests for the DiscussionNotificationSender class
|
||||
"""
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender
|
||||
from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender, \
|
||||
clean_thread_html_body
|
||||
|
||||
|
||||
@patch('lms.djangoapps.discussion.rest_api.discussions_notifications.DiscussionNotificationSender'
|
||||
@@ -44,7 +45,7 @@ class TestDiscussionNotificationSender(unittest.TestCase):
|
||||
|
||||
self.assertEqual(notification_type, "content_reported")
|
||||
self.assertEqual(context, {
|
||||
'username': 'test_user',
|
||||
'username': self.thread.username,
|
||||
'content_type': expected_content_type,
|
||||
'content': 'Thread body'
|
||||
})
|
||||
@@ -88,3 +89,108 @@ class TestDiscussionNotificationSender(unittest.TestCase):
|
||||
self.notification_sender.send_reported_content_notification()
|
||||
|
||||
self._assert_send_notification_called_with(mock_send_notification, 'thread')
|
||||
|
||||
|
||||
class TestCleanThreadHtmlBody(unittest.TestCase):
|
||||
"""
|
||||
Tests for the clean_thread_html_body function
|
||||
"""
|
||||
|
||||
def test_html_tags_removal(self):
|
||||
"""
|
||||
Test that the clean_thread_html_body function removes unwanted HTML tags
|
||||
"""
|
||||
html_body = """
|
||||
<p>This is a <a href="#">link</a> to a page.</p>
|
||||
<p>Here is an image: <img src="image.jpg" alt="image"></p>
|
||||
<p>Embedded video: <iframe src="video.mp4"></iframe></p>
|
||||
<p>Script test: <script>alert('hello');</script></p>
|
||||
<p>Some other content that should remain.</p>
|
||||
"""
|
||||
expected_output = ("<p>This is a link to a page.</p>"
|
||||
"<p>Here is an image: </p>"
|
||||
"<p>Embedded video: </p>"
|
||||
"<p>Script test: alert('hello');</p>"
|
||||
"<p>Some other content that should remain.</p>")
|
||||
|
||||
result = clean_thread_html_body(html_body)
|
||||
|
||||
def normalize_html(text):
|
||||
"""
|
||||
Normalize the output by removing extra whitespace, newlines, and spaces between tags
|
||||
"""
|
||||
text = re.sub(r'\s+', ' ', text).strip() # Replace any sequence of whitespace with a single space
|
||||
text = re.sub(r'>\s+<', '><', text) # Remove spaces between HTML tags
|
||||
return text
|
||||
|
||||
normalized_result = normalize_html(result)
|
||||
normalized_expected_output = normalize_html(expected_output)
|
||||
|
||||
self.assertEqual(normalized_result, normalized_expected_output)
|
||||
|
||||
def test_truncate_html_body(self):
|
||||
"""
|
||||
Test that the clean_thread_html_body function truncates the HTML body to 500 characters
|
||||
"""
|
||||
html_body = """
|
||||
<p>This is a long text that should be truncated to 500 characters.</p>
|
||||
""" * 20 # Repeat to exceed 500 characters
|
||||
|
||||
result = clean_thread_html_body(html_body)
|
||||
self.assertGreaterEqual(500, len(result))
|
||||
|
||||
def test_no_tags_to_remove(self):
|
||||
"""
|
||||
Test that the clean_thread_html_body function does not remove any tags if there are no unwanted tags
|
||||
"""
|
||||
html_body = "<p>This paragraph has no tags to remove.</p>"
|
||||
expected_output = "<p>This paragraph has no tags to remove.</p>"
|
||||
|
||||
result = clean_thread_html_body(html_body)
|
||||
self.assertEqual(result, expected_output)
|
||||
|
||||
def test_empty_html_body(self):
|
||||
"""
|
||||
Test that the clean_thread_html_body function returns an empty string if the input is an empty string
|
||||
"""
|
||||
html_body = ""
|
||||
expected_output = ""
|
||||
|
||||
result = clean_thread_html_body(html_body)
|
||||
self.assertEqual(result, expected_output)
|
||||
|
||||
def test_only_script_tag(self):
|
||||
"""
|
||||
Test that the clean_thread_html_body function removes the script tag and its content
|
||||
"""
|
||||
html_body = "<script>alert('Hello');</script>"
|
||||
expected_output = "alert('Hello');"
|
||||
|
||||
result = clean_thread_html_body(html_body)
|
||||
self.assertEqual(result.strip(), expected_output)
|
||||
|
||||
def test_button_tag_replace(self):
|
||||
"""
|
||||
Tests that the clean_thread_html_body function replaces the button tag with span tag
|
||||
"""
|
||||
# Tests for button replacement tag with text
|
||||
html_body = '<button class="abc">Button</button>'
|
||||
expected_output = '<span class="abc">Button</span>'
|
||||
result = clean_thread_html_body(html_body)
|
||||
self.assertEqual(result, expected_output)
|
||||
|
||||
# Tests button tag replacement without text
|
||||
html_body = '<button class="abc"></button>'
|
||||
expected_output = '<span class="abc"></span>'
|
||||
result = clean_thread_html_body(html_body)
|
||||
self.assertEqual(result, expected_output)
|
||||
|
||||
def test_heading_tag_replace(self):
|
||||
"""
|
||||
Tests that the clean_thread_html_body function replaces the h1, h2 and h3 tags with h4 tag
|
||||
"""
|
||||
for tag in ['h1', 'h2', 'h3']:
|
||||
html_body = f'<{tag}>Heading</{tag}>'
|
||||
expected_output = '<h4>Heading</h4>'
|
||||
result = clean_thread_html_body(html_body)
|
||||
self.assertEqual(result, expected_output)
|
||||
|
||||
@@ -273,6 +273,17 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
})
|
||||
self._register_subscriptions_endpoint()
|
||||
|
||||
self.comment = ThreadMock(thread_id=4, creator=self.user_2, title='test comment', body='comment body')
|
||||
self.register_get_comment_response(
|
||||
{
|
||||
'id': self.comment.id,
|
||||
'thread_id': self.thread.id,
|
||||
'parent_id': None,
|
||||
'user_id': self.comment.user_id,
|
||||
'body': self.comment.body,
|
||||
}
|
||||
)
|
||||
|
||||
def test_basic(self):
|
||||
"""
|
||||
Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin
|
||||
@@ -292,7 +303,13 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
|
||||
# Post the form or do what it takes to send the signal
|
||||
|
||||
send_response_notifications(self.thread.id, str(self.course.id), self.user_2.id, parent_id=None)
|
||||
send_response_notifications(
|
||||
self.thread.id,
|
||||
str(self.course.id),
|
||||
self.user_2.id,
|
||||
self.comment.id,
|
||||
parent_id=None
|
||||
)
|
||||
self.assertEqual(handler.call_count, 2)
|
||||
args = handler.call_args_list[0][1]['notification_data']
|
||||
self.assertEqual([int(user_id) for user_id in args.user_ids], [self.user_1.id])
|
||||
@@ -300,6 +317,7 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
expected_context = {
|
||||
'replier_name': self.user_2.username,
|
||||
'post_title': 'test thread',
|
||||
'email_content': self.comment.body,
|
||||
'course_name': self.course.display_name,
|
||||
'sender_id': self.user_2.id
|
||||
}
|
||||
@@ -325,7 +343,13 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
'user_id': self.thread_2.user_id
|
||||
})
|
||||
|
||||
send_response_notifications(self.thread.id, str(self.course.id), self.user_3.id, parent_id=self.thread_2.id)
|
||||
send_response_notifications(
|
||||
self.thread.id,
|
||||
str(self.course.id),
|
||||
self.user_3.id,
|
||||
self.comment.id,
|
||||
parent_id=self.thread_2.id
|
||||
)
|
||||
# check if 2 call are made to the handler i.e. one for the response creator and one for the thread creator
|
||||
self.assertEqual(handler.call_count, 2)
|
||||
|
||||
@@ -337,7 +361,10 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
expected_context = {
|
||||
'replier_name': self.user_3.username,
|
||||
'post_title': self.thread.title,
|
||||
'email_content': self.comment.body,
|
||||
'group_by_id': self.thread_2.id,
|
||||
'author_name': 'dummy\'s',
|
||||
'author_pronoun': 'dummy\'s',
|
||||
'course_name': self.course.display_name,
|
||||
'sender_id': self.user_3.id
|
||||
}
|
||||
@@ -354,6 +381,7 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
expected_context = {
|
||||
'replier_name': self.user_3.username,
|
||||
'post_title': self.thread.title,
|
||||
'email_content': self.comment.body,
|
||||
'course_name': self.course.display_name,
|
||||
'sender_id': self.user_3.id
|
||||
}
|
||||
@@ -371,7 +399,13 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
"""
|
||||
handler = mock.Mock()
|
||||
USER_NOTIFICATION_REQUESTED.connect(handler)
|
||||
send_response_notifications(self.thread.id, str(self.course.id), self.user_1.id, parent_id=None)
|
||||
|
||||
send_response_notifications(
|
||||
self.thread.id,
|
||||
str(self.course.id),
|
||||
self.user_1.id,
|
||||
self.comment.id, parent_id=None
|
||||
)
|
||||
self.assertEqual(handler.call_count, 1)
|
||||
|
||||
def test_comment_creators_own_response(self):
|
||||
@@ -388,7 +422,13 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
'user_id': self.thread_3.user_id
|
||||
})
|
||||
|
||||
send_response_notifications(self.thread.id, str(self.course.id), self.user_3.id, parent_id=self.thread_2.id)
|
||||
send_response_notifications(
|
||||
self.thread.id,
|
||||
str(self.course.id),
|
||||
self.user_3.id,
|
||||
parent_id=self.thread_2.id,
|
||||
comment_id=self.comment.id
|
||||
)
|
||||
# check if 1 call is made to the handler i.e. for the thread creator
|
||||
self.assertEqual(handler.call_count, 2)
|
||||
|
||||
@@ -399,9 +439,12 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
expected_context = {
|
||||
'replier_name': self.user_3.username,
|
||||
'post_title': self.thread.title,
|
||||
'author_name': 'your',
|
||||
'group_by_id': self.thread_2.id,
|
||||
'author_name': 'dummy\'s',
|
||||
'author_pronoun': 'your',
|
||||
'course_name': self.course.display_name,
|
||||
'sender_id': self.user_3.id,
|
||||
'email_content': self.comment.body
|
||||
}
|
||||
self.assertDictEqual(args_comment.context, expected_context)
|
||||
self.assertEqual(
|
||||
@@ -427,7 +470,13 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
USER_NOTIFICATION_REQUESTED.connect(handler)
|
||||
|
||||
# Post the form or do what it takes to send the signal
|
||||
notification_sender = DiscussionNotificationSender(self.thread, self.course, self.user_2, parent_id=parent_id)
|
||||
notification_sender = DiscussionNotificationSender(
|
||||
self.thread,
|
||||
self.course,
|
||||
self.user_2,
|
||||
parent_id=parent_id,
|
||||
comment_id=self.comment.id
|
||||
)
|
||||
notification_sender.send_response_on_followed_post_notification()
|
||||
self.assertEqual(handler.call_count, 1)
|
||||
args = handler.call_args[1]['notification_data']
|
||||
@@ -437,11 +486,13 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
expected_context = {
|
||||
'replier_name': self.user_2.username,
|
||||
'post_title': 'test thread',
|
||||
'email_content': self.comment.body,
|
||||
'course_name': self.course.display_name,
|
||||
'sender_id': self.user_2.id,
|
||||
}
|
||||
if parent_id:
|
||||
expected_context['author_name'] = 'dummy'
|
||||
expected_context['author_name'] = 'dummy\'s'
|
||||
expected_context['author_pronoun'] = 'dummy\'s'
|
||||
self.assertDictEqual(args.context, expected_context)
|
||||
self.assertEqual(
|
||||
args.content_url,
|
||||
@@ -513,6 +564,7 @@ class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCas
|
||||
|
||||
thread = ThreadMock(thread_id=1, creator=self.user_1, title='test thread')
|
||||
response = ThreadMock(thread_id=2, creator=self.user_2, title='test response')
|
||||
comment = ThreadMock(thread_id=3, creator=self.user_2, title='test comment', body='comment body')
|
||||
self.register_get_thread_response({
|
||||
'id': thread.id,
|
||||
'course_id': str(self.course.id),
|
||||
@@ -527,11 +579,20 @@ class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCas
|
||||
'thread_id': thread.id,
|
||||
'user_id': response.user_id
|
||||
})
|
||||
self.register_get_comment_response({
|
||||
'id': comment.id,
|
||||
'parent_id': response.id,
|
||||
'user_id': comment.user_id,
|
||||
'body': comment.body
|
||||
})
|
||||
self.register_get_subscriptions(1, {})
|
||||
send_response_notifications(thread.id, str(self.course.id), self.user_2.id, parent_id=response.id)
|
||||
send_response_notifications(thread.id, str(self.course.id), self.user_2.id, parent_id=response.id,
|
||||
comment_id=comment.id)
|
||||
handler.assert_called_once()
|
||||
context = handler.call_args[1]['notification_data'].context
|
||||
self.assertEqual(context['author_name'], 'their')
|
||||
self.assertEqual(context['author_name'], 'dummy\'s')
|
||||
self.assertEqual(context['author_pronoun'], 'their')
|
||||
self.assertEqual(context['email_content'], comment.body)
|
||||
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
@@ -604,6 +665,7 @@ class TestResponseEndorsedNotifications(DiscussionAPIViewTestMixin, ModuleStoreT
|
||||
'post_title': 'test thread',
|
||||
'course_name': self.course.display_name,
|
||||
'sender_id': int(self.user_2.id),
|
||||
'email_content': 'dummy'
|
||||
}
|
||||
self.assertDictEqual(notification_data.context, expected_context)
|
||||
self.assertEqual(notification_data.content_url, _get_mfe_url(self.course.id, thread.id))
|
||||
@@ -621,6 +683,7 @@ class TestResponseEndorsedNotifications(DiscussionAPIViewTestMixin, ModuleStoreT
|
||||
'post_title': 'test thread',
|
||||
'course_name': self.course.display_name,
|
||||
'sender_id': int(response.user_id),
|
||||
'email_content': 'dummy'
|
||||
}
|
||||
self.assertDictEqual(notification_data.context, expected_context)
|
||||
self.assertEqual(notification_data.content_url, _get_mfe_url(self.course.id, thread.id))
|
||||
|
||||
@@ -675,12 +675,13 @@ class ThreadMock(object):
|
||||
A mock thread object
|
||||
"""
|
||||
|
||||
def __init__(self, thread_id, creator, title, parent_id=None):
|
||||
def __init__(self, thread_id, creator, title, parent_id=None, body=''):
|
||||
self.id = thread_id
|
||||
self.user_id = str(creator.id)
|
||||
self.username = creator.username
|
||||
self.title = title
|
||||
self.parent_id = parent_id
|
||||
self.body = body
|
||||
|
||||
def url_with_id(self, params):
|
||||
return f"http://example.com/{params['id']}"
|
||||
|
||||
@@ -109,8 +109,10 @@ def create_message_context(comment, site):
|
||||
'course_id': str(thread.course_id),
|
||||
'comment_id': comment.id,
|
||||
'comment_body': comment.body,
|
||||
'comment_body_text': comment.body_text,
|
||||
'comment_author_id': comment.user_id,
|
||||
'comment_created_at': comment.created_at, # comment_client models dates are already serialized
|
||||
'comment_parent_id': comment.parent_id,
|
||||
'thread_id': thread.id,
|
||||
'thread_title': thread.title,
|
||||
'thread_author_id': thread.user_id,
|
||||
@@ -176,8 +178,9 @@ def create_comment_created_notification(*args, **kwargs):
|
||||
comment = kwargs['post']
|
||||
thread_id = comment.attributes['thread_id']
|
||||
parent_id = comment.attributes['parent_id']
|
||||
comment_id = comment.attributes['id']
|
||||
course_key_str = comment.attributes['course_id']
|
||||
send_response_notifications.apply_async(args=[thread_id, course_key_str, user.id, parent_id])
|
||||
send_response_notifications.apply_async(args=[thread_id, course_key_str, user.id, comment_id, parent_id])
|
||||
|
||||
|
||||
@receiver(signals.comment_endorsed)
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.conf import settings # lint-amnesty, pylint: disable=unused-import
|
||||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
||||
from django.contrib.sites.models import Site
|
||||
from edx_ace import ace
|
||||
from edx_ace.channel import ChannelType
|
||||
from edx_ace.recipient import Recipient
|
||||
from edx_ace.utils import date
|
||||
from edx_django_utils.monitoring import set_code_owner_attribute
|
||||
@@ -74,6 +75,12 @@ class ReportedContentNotification(BaseMessageType):
|
||||
self.options['transactional'] = True
|
||||
|
||||
|
||||
class CommentNotification(BaseMessageType):
|
||||
"""
|
||||
Notify discussion participants of new comments.
|
||||
"""
|
||||
|
||||
|
||||
@shared_task(base=LoggedTask)
|
||||
@set_code_owner_attribute
|
||||
def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
@@ -82,17 +89,40 @@ def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function
|
||||
if _should_send_message(context):
|
||||
context['site'] = Site.objects.get(id=context['site_id'])
|
||||
thread_author = User.objects.get(id=context['thread_author_id'])
|
||||
with emulate_http_request(site=context['site'], user=thread_author):
|
||||
message_context = _build_message_context(context)
|
||||
comment_author = User.objects.get(id=context['comment_author_id'])
|
||||
with emulate_http_request(site=context['site'], user=comment_author):
|
||||
message_context = _build_message_context(context, notification_type='forum_response')
|
||||
message = ResponseNotification().personalize(
|
||||
Recipient(thread_author.id, thread_author.email),
|
||||
_get_course_language(context['course_id']),
|
||||
message_context
|
||||
)
|
||||
log.info('Sending forum comment email notification with context %s', message_context)
|
||||
ace.send(message)
|
||||
log.info('Sending forum comment notification with context %s', message_context)
|
||||
if _is_first_comment(context['comment_id'], context['thread_id']):
|
||||
limit_to_channels = None
|
||||
else:
|
||||
limit_to_channels = [ChannelType.PUSH]
|
||||
ace.send(message, limit_to_channels=limit_to_channels)
|
||||
_track_notification_sent(message, context)
|
||||
|
||||
elif _should_send_subcomment_message(context):
|
||||
context['site'] = Site.objects.get(id=context['site_id'])
|
||||
comment_author = User.objects.get(id=context['comment_author_id'])
|
||||
thread_author = User.objects.get(id=context['thread_author_id'])
|
||||
|
||||
with emulate_http_request(site=context['site'], user=comment_author):
|
||||
message_context = _build_message_context(context)
|
||||
message = CommentNotification().personalize(
|
||||
Recipient(thread_author.id, thread_author.email),
|
||||
_get_course_language(context['course_id']),
|
||||
message_context
|
||||
)
|
||||
log.info('Sending forum comment notification with context %s', message_context)
|
||||
ace.send(message, limit_to_channels=[ChannelType.PUSH])
|
||||
_track_notification_sent(message, context)
|
||||
else:
|
||||
return
|
||||
|
||||
|
||||
@shared_task(base=LoggedTask)
|
||||
@set_code_owner_attribute
|
||||
@@ -154,19 +184,36 @@ def _should_send_message(context):
|
||||
return (
|
||||
_is_user_subscribed_to_thread(cc_thread_author, context['thread_id']) and
|
||||
_is_not_subcomment(context['comment_id']) and
|
||||
_is_first_comment(context['comment_id'], context['thread_id'])
|
||||
not _comment_author_is_thread_author(context)
|
||||
)
|
||||
|
||||
|
||||
def _should_send_subcomment_message(context):
|
||||
cc_thread_author = cc.User(id=context['thread_author_id'], course_id=context['course_id'])
|
||||
return (
|
||||
_is_user_subscribed_to_thread(cc_thread_author, context['thread_id']) and
|
||||
_is_subcomment(context['comment_id']) and
|
||||
not _comment_author_is_thread_author(context)
|
||||
)
|
||||
|
||||
|
||||
def _comment_author_is_thread_author(context):
|
||||
return context.get('comment_author_id', '') == context['thread_author_id']
|
||||
|
||||
|
||||
def _is_content_still_reported(context):
|
||||
if context.get('comment_id') is not None:
|
||||
return len(cc.Comment.find(context['comment_id']).abuse_flaggers) > 0
|
||||
return len(cc.Thread.find(context['thread_id']).abuse_flaggers) > 0
|
||||
|
||||
|
||||
def _is_not_subcomment(comment_id):
|
||||
def _is_subcomment(comment_id):
|
||||
comment = cc.Comment.find(id=comment_id).retrieve()
|
||||
return not getattr(comment, 'parent_id', None)
|
||||
return getattr(comment, 'parent_id', None)
|
||||
|
||||
|
||||
def _is_not_subcomment(comment_id):
|
||||
return not _is_subcomment(comment_id)
|
||||
|
||||
|
||||
def _is_first_comment(comment_id, thread_id): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
@@ -204,7 +251,7 @@ def _get_course_language(course_id):
|
||||
return language
|
||||
|
||||
|
||||
def _build_message_context(context): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
def _build_message_context(context, notification_type='forum_comment'): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
message_context = get_base_template_context(context['site'])
|
||||
message_context.update(context)
|
||||
thread_author = User.objects.get(id=context['thread_author_id'])
|
||||
@@ -218,6 +265,14 @@ def _build_message_context(context): # lint-amnesty, pylint: disable=missing-fu
|
||||
'thread_username': thread_author.username,
|
||||
'comment_username': comment_author.username,
|
||||
'post_link': post_link,
|
||||
'push_notification_extra_context': {
|
||||
'course_id': str(context['course_id']),
|
||||
'parent_id': str(context['comment_parent_id']),
|
||||
'notification_type': notification_type,
|
||||
'topic_id': str(context['thread_commentable_id']),
|
||||
'thread_id': context['thread_id'],
|
||||
'comment_id': context['comment_id'],
|
||||
},
|
||||
'comment_created_at': date.deserialize(context['comment_created_at']),
|
||||
'thread_created_at': date.deserialize(context['thread_created_at'])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{% load i18n %}
|
||||
{% blocktrans trimmed %}{{ comment_username }} commented to {{ thread_title }}:{% endblocktrans %}
|
||||
{{ comment_body_text }}
|
||||
@@ -0,0 +1,2 @@
|
||||
{% load i18n %}
|
||||
{% blocktrans %}Comment to {{ thread_title }}{% endblocktrans %}
|
||||
@@ -0,0 +1,2 @@
|
||||
{% load i18n %}
|
||||
{% blocktrans trimmed %}{{ comment_username }} replied to {{ thread_title }}: {{ comment_body|truncatechars:200 }}{% endblocktrans %}
|
||||
@@ -0,0 +1,2 @@
|
||||
{% load i18n %}
|
||||
{% blocktrans %}Response to {{ thread_title }}{% endblocktrans %}
|
||||
@@ -19,7 +19,11 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
import openedx.core.djangoapps.django_comment_common.comment_client as cc
|
||||
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from lms.djangoapps.discussion.signals.handlers import ENABLE_FORUM_NOTIFICATIONS_FOR_SITE_KEY
|
||||
from lms.djangoapps.discussion.tasks import _should_send_message, _track_notification_sent
|
||||
from lms.djangoapps.discussion.tasks import (
|
||||
_is_first_comment,
|
||||
_should_send_message,
|
||||
_track_notification_sent,
|
||||
)
|
||||
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
from openedx.core.djangoapps.django_comment_common.models import ForumsConfig
|
||||
@@ -222,6 +226,8 @@ class TaskTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missin
|
||||
|
||||
self.ace_send_patcher = mock.patch('edx_ace.ace.send')
|
||||
self.mock_ace_send = self.ace_send_patcher.start()
|
||||
self.mock_message_patcher = mock.patch('lms.djangoapps.discussion.tasks.ResponseNotification')
|
||||
self.mock_message = self.mock_message_patcher.start()
|
||||
|
||||
thread_permalink = '/courses/discussion/dummy_discussion_id'
|
||||
self.permalink_patcher = mock.patch('lms.djangoapps.discussion.tasks.permalink', return_value=thread_permalink)
|
||||
@@ -231,10 +237,12 @@ class TaskTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missin
|
||||
super().tearDown()
|
||||
self.request_patcher.stop()
|
||||
self.ace_send_patcher.stop()
|
||||
self.mock_message_patcher.stop()
|
||||
self.permalink_patcher.stop()
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_send_discussion_email_notification(self, user_subscribed):
|
||||
self.mock_message_patcher.stop()
|
||||
if user_subscribed:
|
||||
non_matching_id = 'not-a-match'
|
||||
# with per_page left with a default value of 1, this ensures
|
||||
@@ -271,8 +279,10 @@ class TaskTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missin
|
||||
expected_message_context.update({
|
||||
'comment_author_id': self.comment_author.id,
|
||||
'comment_body': comment['body'],
|
||||
'comment_body_text': comment.body_text,
|
||||
'comment_created_at': ONE_HOUR_AGO,
|
||||
'comment_id': comment['id'],
|
||||
'comment_parent_id': comment['parent_id'],
|
||||
'comment_username': self.comment_author.username,
|
||||
'course_id': self.course.id,
|
||||
'thread_author_id': self.thread_author.id,
|
||||
@@ -283,7 +293,15 @@ class TaskTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missin
|
||||
'thread_commentable_id': thread['commentable_id'],
|
||||
'post_link': f'https://{site.domain}{self.mock_permalink.return_value}',
|
||||
'site': site,
|
||||
'site_id': site.id
|
||||
'site_id': site.id,
|
||||
'push_notification_extra_context': {
|
||||
'notification_type': 'forum_response',
|
||||
'topic_id': thread['commentable_id'],
|
||||
'course_id': comment['course_id'],
|
||||
'parent_id': str(comment['parent_id']),
|
||||
'thread_id': thread['id'],
|
||||
'comment_id': comment['id'],
|
||||
},
|
||||
})
|
||||
expected_recipient = Recipient(self.thread_author.id, self.thread_author.email)
|
||||
actual_message = self.mock_ace_send.call_args_list[0][0][0]
|
||||
@@ -326,7 +344,9 @@ class TaskTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missin
|
||||
'comment_id': comment_dict['id'],
|
||||
'thread_id': thread['id'],
|
||||
})
|
||||
assert actual_result is False
|
||||
|
||||
should_email_send = _is_first_comment(comment_dict['id'], thread['id'])
|
||||
assert not should_email_send
|
||||
assert not self.mock_ace_send.called
|
||||
|
||||
def test_subcomment_should_not_send_email(self):
|
||||
|
||||
@@ -3,9 +3,9 @@ Custom exceptions raised by grades.
|
||||
"""
|
||||
|
||||
|
||||
class DatabaseNotReadyError(IOError):
|
||||
class ScoreNotFoundError(IOError):
|
||||
"""
|
||||
Subclass of IOError to indicate the database has not yet committed
|
||||
the data we're trying to find.
|
||||
Subclass of IOError to indicate the staff has not yet graded the problem or
|
||||
the database has not yet committed the data we're trying to find.
|
||||
"""
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
@@ -302,7 +302,7 @@ class SectionGradesBreakdownTest(GradeViewTestMixin, APITestCase):
|
||||
+ [
|
||||
{
|
||||
'category': 'Homework',
|
||||
'detail': 'Homework Average = 0%',
|
||||
'detail': 'Homework Average = 0.00%',
|
||||
'label': 'HW Avg', 'percent': 0.0,
|
||||
'prominent': True
|
||||
}
|
||||
@@ -332,21 +332,21 @@ class SectionGradesBreakdownTest(GradeViewTestMixin, APITestCase):
|
||||
},
|
||||
{
|
||||
'category': 'Lab',
|
||||
'detail': 'Lab Average = 0%',
|
||||
'detail': 'Lab Average = 0.00%',
|
||||
'label': 'Lab Avg',
|
||||
'percent': 0.0,
|
||||
'prominent': True
|
||||
},
|
||||
{
|
||||
'category': 'Midterm Exam',
|
||||
'detail': 'Midterm Exam = 0%',
|
||||
'detail': 'Midterm Exam = 0.00%',
|
||||
'label': 'Midterm',
|
||||
'percent': 0.0,
|
||||
'prominent': True
|
||||
},
|
||||
{
|
||||
'category': 'Final Exam',
|
||||
'detail': 'Final Exam = 0%',
|
||||
'detail': 'Final Exam = 0.00%',
|
||||
'label': 'Final',
|
||||
'percent': 0.0,
|
||||
'prominent': True
|
||||
|
||||
@@ -162,8 +162,8 @@ def compute_percent(earned, possible):
|
||||
Returns the percentage of the given earned and possible values.
|
||||
"""
|
||||
if possible > 0:
|
||||
# Rounds to two decimal places.
|
||||
return around(earned / possible, decimals=2)
|
||||
# Rounds to four decimal places.
|
||||
return around(earned / possible, decimals=4)
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disa
|
||||
from .config.waffle import DISABLE_REGRADE_ON_POLICY_CHANGE
|
||||
from .constants import ScoreDatabaseTableEnum
|
||||
from .course_grade_factory import CourseGradeFactory
|
||||
from .exceptions import DatabaseNotReadyError
|
||||
from .exceptions import ScoreNotFoundError
|
||||
from .grade_utils import are_grades_frozen
|
||||
from .signals.signals import SUBSECTION_SCORE_CHANGED
|
||||
from .subsection_grade_factory import SubsectionGradeFactory
|
||||
@@ -45,7 +45,7 @@ COURSE_GRADE_TIMEOUT_SECONDS = 1200
|
||||
KNOWN_RETRY_ERRORS = ( # Errors we expect occasionally, should be resolved on retry
|
||||
DatabaseError,
|
||||
ValidationError,
|
||||
DatabaseNotReadyError,
|
||||
ScoreNotFoundError,
|
||||
UsageKeyNotInBlockStructure,
|
||||
)
|
||||
RECALCULATE_GRADE_DELAY_SECONDS = 2 # to prevent excessive _has_db_updated failures. See TNL-6424.
|
||||
@@ -239,7 +239,7 @@ def _recalculate_subsection_grade(self, **kwargs):
|
||||
has_database_updated = _has_db_updated_with_new_score(self, scored_block_usage_key, **kwargs)
|
||||
|
||||
if not has_database_updated:
|
||||
raise DatabaseNotReadyError
|
||||
raise ScoreNotFoundError
|
||||
|
||||
_update_subsection_grades(
|
||||
course_key,
|
||||
|
||||
@@ -185,26 +185,26 @@ class TestCourseGradeFactory(GradeTestBase):
|
||||
'section_breakdown': [
|
||||
{
|
||||
'category': 'Homework',
|
||||
'detail': 'Homework 1 - Test Sequential X with an & Ampersand - 50% (1/2)',
|
||||
'detail': 'Homework 1 - Test Sequential X with an & Ampersand - 50.00% (1/2)',
|
||||
'label': 'HW 01',
|
||||
'percent': 0.5
|
||||
},
|
||||
{
|
||||
'category': 'Homework',
|
||||
'detail': 'Homework 2 - Test Sequential A - 0% (0/1)',
|
||||
'detail': 'Homework 2 - Test Sequential A - 0.00% (0/1)',
|
||||
'label': 'HW 02',
|
||||
'percent': 0.0
|
||||
},
|
||||
{
|
||||
'category': 'Homework',
|
||||
'detail': 'Homework Average = 25%',
|
||||
'detail': 'Homework Average = 25.00%',
|
||||
'label': 'HW Avg',
|
||||
'percent': 0.25,
|
||||
'prominent': True
|
||||
},
|
||||
{
|
||||
'category': 'NoCredit',
|
||||
'detail': 'NoCredit Average = 0%',
|
||||
'detail': 'NoCredit Average = 0.00%',
|
||||
'label': 'NC Avg',
|
||||
'percent': 0,
|
||||
'prominent': True
|
||||
|
||||
@@ -14,7 +14,7 @@ from .utils import mock_get_score
|
||||
@ddt
|
||||
class SubsectionGradeTest(GradeTestBase): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
|
||||
@data((50, 100, .50), (59.49, 100, .59), (59.51, 100, .60), (59.50, 100, .60), (60.5, 100, .60))
|
||||
@data((50, 100, .5), (.5949, 100, .0059), (.5951, 100, .006), (.595, 100, .0059), (.605, 100, .006))
|
||||
@unpack
|
||||
def test_create_and_read(self, mock_earned, mock_possible, expected_result):
|
||||
with mock_get_score(mock_earned, mock_possible):
|
||||
|
||||
@@ -86,8 +86,8 @@ def _change_access(course, user, level, action, send_email=True):
|
||||
course_id=course.id,
|
||||
student_email=user.email,
|
||||
auto_enroll=True,
|
||||
email_students=send_email,
|
||||
email_params=email_params,
|
||||
message_students=send_email,
|
||||
message_params=email_params,
|
||||
)
|
||||
role.add_users(user)
|
||||
elif action == 'revoke':
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user