Merge branch 'master' into kireiev/AXM-549/feat/upstream_PR_active_inactive_courses_API

This commit is contained in:
Glib Glugovskiy
2024-10-03 10:56:06 +03:00
committed by GitHub
326 changed files with 10701 additions and 5960 deletions

4
.github/CODEOWNERS vendored
View File

@@ -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/

View File

@@ -10,7 +10,7 @@ jobs:
matrix:
python-version:
- "3.11"
os: ["ubuntu-20.04"]
os: ["ubuntu-latest"]
steps:
- uses: actions/checkout@v4

View File

@@ -15,7 +15,7 @@ defaults:
jobs:
recompile-python-dependencies:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
steps:
- name: Check out target branch

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -7,7 +7,7 @@ on:
jobs:
push:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
steps:
- name: Checkout

View File

@@ -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

View File

@@ -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]

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: "${{ matrix.os }}"
strategy:
matrix:
os: ["ubuntu-20.04"]
os: ["ubuntu-latest"]
python-version:
- "3.11"

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -3,7 +3,7 @@ version: 2
build:
os: "ubuntu-22.04"
tools:
python: "3.8"
python: "3.12"
sphinx:
configuration: docs/conf.py

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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": [],

View File

@@ -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)

View File

@@ -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": [

View File

@@ -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": [],

View File

@@ -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

View File

@@ -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)

View File

@@ -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>"

View File

@@ -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|&nbsp;|//)+", " ", html_to_text(content))
text_content = re.sub(r"(\s|&nbsp;|//)+", " ", 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={},

View File

@@ -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'))

View File

@@ -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):
"""

View File

@@ -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",

View File

@@ -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)

View File

@@ -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(

View File

@@ -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:

View File

@@ -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 = []

View File

@@ -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

View File

@@ -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', [])

View File

@@ -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

View File

@@ -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" -->

View File

@@ -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>")

View File

@@ -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>

View File

@@ -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)

View File

@@ -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

View File

@@ -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))

View File

@@ -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)

View File

@@ -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'
)
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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,

View 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;
}

View File

@@ -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/'),
}

View 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="[&quot;display_name&quot;,&quot;max_attempts&quot;]"
upstream_display_name="The title that was defined in the library block"
upstream_max_attempts="3"
>
<!-- problem content would go here -->
</problem>

View File

@@ -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

View File

@@ -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
**************

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
}
)

View File

@@ -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))

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()"

View File

@@ -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']

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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))

View File

@@ -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:

View File

@@ -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

View File

@@ -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,

View File

@@ -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()

View File

@@ -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,

View File

@@ -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):
"""

View File

@@ -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]:

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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))

View File

@@ -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']}"

View File

@@ -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)

View File

@@ -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'])
})

View File

@@ -0,0 +1,3 @@
{% load i18n %}
{% blocktrans trimmed %}{{ comment_username }} commented to {{ thread_title }}:{% endblocktrans %}
{{ comment_body_text }}

View File

@@ -0,0 +1,2 @@
{% load i18n %}
{% blocktrans %}Comment to {{ thread_title }}{% endblocktrans %}

View File

@@ -0,0 +1,2 @@
{% load i18n %}
{% blocktrans trimmed %}{{ comment_username }} replied to {{ thread_title }}: {{ comment_body|truncatechars:200 }}{% endblocktrans %}

View File

@@ -0,0 +1,2 @@
{% load i18n %}
{% blocktrans %}Response to {{ thread_title }}{% endblocktrans %}

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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):

View File

@@ -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