Merge branch 'master' into arslan/6850-add-lti-logging

This commit is contained in:
Peter Pinch
2025-11-26 17:09:59 -05:00
committed by GitHub
29 changed files with 381 additions and 161 deletions

View File

@@ -180,7 +180,8 @@ class ThreadActionGroupIdTestCase(
with mock.patch(
"openedx.core.djangoapps.django_comment_common.signals.thread_flagged.send"
) as signal_mock:
response = self.call_view("flag_abuse_for_thread", "update_thread_flag")
with self.captureOnCommitCallbacks(execute=True):
response = self.call_view("flag_abuse_for_thread", "update_thread_flag")
self._assert_json_response_contains_group_info(response)
self.assertEqual(signal_mock.call_count, 1)
response = self.call_view("un_flag_abuse_for_thread", "update_thread_flag")
@@ -471,10 +472,15 @@ class ViewsTestCase(
def assert_discussion_signals(self, signal, user=None):
if user is None:
user = self.student
# Use captureOnCommitCallbacks to execute on_commit callbacks during tests,
# since signals are now deferred until after transaction commit.
# Order matters: assert_signal_sent must be outer context so the signal
# fires (via captureOnCommitCallbacks) before the assertion check.
with self.assert_signal_sent(
views, signal, sender=None, user=user, exclude_args=("post",)
):
yield
with self.captureOnCommitCallbacks(execute=True):
yield
def test_create_thread(self):
with self.assert_discussion_signals("thread_created"):
@@ -1218,7 +1224,8 @@ class CommentActionTestCase(CohortedTestCase, MockForumApiMixin):
with mock.patch(
"openedx.core.djangoapps.django_comment_common.signals.comment_flagged.send"
) as signal_mock:
self.call_view("flag_abuse_for_comment", "update_comment_flag")
with self.captureOnCommitCallbacks(execute=True):
self.call_view("flag_abuse_for_comment", "update_comment_flag")
self.assertEqual(signal_mock.call_count, 1)

View File

@@ -50,6 +50,7 @@ from lms.djangoapps.discussion.django_comment_client.utils import (
prepare_content,
sanitize_body
)
from lms.djangoapps.discussion.rest_api.utils import send_signal_after_commit
from openedx.core.djangoapps.django_comment_common.signals import (
comment_created,
comment_deleted,
@@ -587,7 +588,10 @@ def create_thread(request, course_id, commentable_id):
thread.save()
thread_created.send(sender=None, user=user, post=thread)
# Use send_signal_after_commit() to ensure the signal is sent only after the transaction commits.
send_signal_after_commit(
lambda: thread_created.send(sender=None, user=user, post=thread)
)
# patch for backward compatibility to comments service
if 'pinned' not in thread.attributes:
@@ -598,7 +602,9 @@ def create_thread(request, course_id, commentable_id):
if follow:
cc_user = cc.User.from_django_user(user)
cc_user.follow(thread, course_id)
thread_followed.send(sender=None, user=user, post=thread)
send_signal_after_commit(
lambda: thread_followed.send(sender=None, user=user, post=thread)
)
data = thread.to_dict()
@@ -645,7 +651,9 @@ def update_thread(request, course_id, thread_id):
thread.save()
thread_edited.send(sender=None, user=user, post=thread)
send_signal_after_commit(
lambda: thread_edited.send(sender=None, user=user, post=thread)
)
track_thread_edited_event(request, course, thread, None)
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
@@ -688,7 +696,9 @@ def _create_comment(request, course_key, thread_id=None, parent_id=None):
)
comment.save(params={"course_id": str(course_key)})
comment_created.send(sender=None, user=user, post=comment)
send_signal_after_commit(
lambda: comment_created.send(sender=None, user=user, post=comment)
)
followed = post.get('auto_subscribe', 'false').lower() == 'true'
@@ -729,7 +739,9 @@ def delete_thread(request, course_id, thread_id):
course = get_course_with_access(request.user, 'load', course_key)
thread = cc.Thread.find(thread_id)
thread.delete(course_id=course_id)
thread_deleted.send(sender=None, user=request.user, post=thread)
send_signal_after_commit(
lambda: thread_deleted.send(sender=None, user=request.user, post=thread)
)
track_thread_deleted_event(request, course, thread)
return JsonResponse(prepare_content(thread.to_dict(), course_key))
@@ -751,7 +763,9 @@ def update_comment(request, course_id, comment_id):
comment.body = sanitize_body(request.POST["body"])
comment.save(params={"course_id": course_id})
comment_edited.send(sender=None, user=request.user, post=comment)
send_signal_after_commit(
lambda: comment_edited.send(sender=None, user=request.user, post=comment)
)
track_comment_edited_event(request, course, comment, None)
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
@@ -776,7 +790,9 @@ def endorse_comment(request, course_id, comment_id):
comment.endorsed = endorsed
comment.endorsement_user_id = user.id
comment.save(params={"course_id": course_id})
comment_endorsed.send(sender=None, user=user, post=comment)
send_signal_after_commit(
lambda: comment_endorsed.send(sender=None, user=user, post=comment)
)
track_forum_response_mark_event(request, course, comment, endorsed)
return JsonResponse(prepare_content(comment.to_dict(), course_key))
@@ -828,7 +844,9 @@ def delete_comment(request, course_id, comment_id):
course = get_course_with_access(request.user, 'load', course_key)
comment = cc.Comment.find(comment_id)
comment.delete(course_id=course_id)
comment_deleted.send(sender=None, user=request.user, post=comment)
send_signal_after_commit(
lambda: comment_deleted.send(sender=None, user=request.user, post=comment)
)
track_comment_deleted_event(request, course, comment)
return JsonResponse(prepare_content(comment.to_dict(), course_key))
@@ -847,7 +865,9 @@ def _vote_or_unvote(request, course_id, obj, value='up', undo_vote=False):
# (People could theoretically downvote by handcrafting AJAX requests.)
else:
user.vote(obj, value, course_id)
thread_voted.send(sender=None, user=request.user, post=obj)
send_signal_after_commit(
lambda: thread_voted.send(sender=None, user=request.user, post=obj)
)
track_voted_event(request, course, obj, value, undo_vote)
return JsonResponse(prepare_content(obj.to_dict(), course_key))
@@ -861,7 +881,9 @@ def vote_for_comment(request, course_id, comment_id, value):
"""
comment = cc.Comment.find(comment_id)
result = _vote_or_unvote(request, course_id, comment, value)
comment_voted.send(sender=None, user=request.user, post=comment)
send_signal_after_commit(
lambda: comment_voted.send(sender=None, user=request.user, post=comment)
)
return result
@@ -914,7 +936,9 @@ def flag_abuse_for_thread(request, course_id, thread_id):
thread = cc.Thread.find(thread_id)
thread.flagAbuse(user, thread, course_id)
track_discussion_reported_event(request, course, thread)
thread_flagged.send(sender='flag_abuse_for_thread', user=request.user, post=thread)
send_signal_after_commit(
lambda: thread_flagged.send(sender='flag_abuse_for_thread', user=request.user, post=thread)
)
return JsonResponse(prepare_content(thread.to_dict(), course_key))
@@ -953,7 +977,9 @@ def flag_abuse_for_comment(request, course_id, comment_id):
comment = cc.Comment.find(comment_id)
comment.flagAbuse(user, comment, course_id)
track_discussion_reported_event(request, course, comment)
comment_flagged.send(sender='flag_abuse_for_comment', user=request.user, post=comment)
send_signal_after_commit(
lambda: comment_flagged.send(sender='flag_abuse_for_comment', user=request.user, post=comment)
)
return JsonResponse(prepare_content(comment.to_dict(), course_key))
@@ -1019,7 +1045,9 @@ def follow_thread(request, course_id, thread_id): # lint-amnesty, pylint: disab
course = get_course_by_id(course_key)
thread = cc.Thread.find(thread_id)
user.follow(thread, course_id=course_id)
thread_followed.send(sender=None, user=request.user, post=thread)
send_signal_after_commit(
lambda: thread_followed.send(sender=None, user=request.user, post=thread)
)
track_thread_followed_event(request, course, thread, True)
return JsonResponse({})
@@ -1051,7 +1079,9 @@ def unfollow_thread(request, course_id, thread_id): # lint-amnesty, pylint: dis
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
user.unfollow(thread, course_id=course_id)
thread_unfollowed.send(sender=None, user=request.user, post=thread)
send_signal_after_commit(
lambda: thread_unfollowed.send(sender=None, user=request.user, post=thread)
)
track_thread_followed_event(request, course, thread, False)
return JsonResponse({})

View File

@@ -128,6 +128,7 @@ from .utils import (
discussion_open_for_user,
get_usernames_for_course,
get_usernames_from_search_string,
send_signal_after_commit,
set_attribute,
is_posting_allowed,
can_user_notify_all_learners, is_captcha_enabled, get_captcha_site_key_by_platform
@@ -1382,7 +1383,9 @@ def _handle_following_field(form_value, user, cc_content, request):
else:
user.unfollow(cc_content)
signal = thread_followed if form_value else thread_unfollowed
signal.send(sender=None, user=user, post=cc_content)
send_signal_after_commit(
lambda: signal.send(sender=None, user=user, post=cc_content)
)
track_thread_followed_event(request, course, cc_content, form_value)
@@ -1395,9 +1398,13 @@ def _handle_abuse_flagged_field(form_value, user, cc_content, request):
track_discussion_reported_event(request, course, cc_content)
if ENABLE_DISCUSSIONS_MFE.is_enabled(course_key):
if cc_content.type == 'thread':
thread_flagged.send(sender='flag_abuse_for_thread', user=user, post=cc_content)
send_signal_after_commit(
lambda: thread_flagged.send(sender='flag_abuse_for_thread', user=user, post=cc_content)
)
else:
comment_flagged.send(sender='flag_abuse_for_comment', user=user, post=cc_content)
send_signal_after_commit(
lambda: comment_flagged.send(sender='flag_abuse_for_comment', user=user, post=cc_content)
)
else:
remove_all = bool(is_privileged_user(course_key, User.objects.get(id=user.id)))
cc_content.unFlagAbuse(user, cc_content, remove_all)
@@ -1407,7 +1414,9 @@ def _handle_abuse_flagged_field(form_value, user, cc_content, request):
def _handle_voted_field(form_value, cc_content, api_content, request, context):
"""vote or undo vote on thread/comment"""
signal = thread_voted if cc_content.type == 'thread' else comment_voted
signal.send(sender=None, user=context["request"].user, post=cc_content)
send_signal_after_commit(
lambda: signal.send(sender=None, user=context["request"].user, post=cc_content)
)
if form_value:
context["cc_requester"].vote(cc_content, "up")
api_content["vote_count"] += 1
@@ -1452,7 +1461,9 @@ def _handle_comment_signals(update_data, comment, user, sender=None):
"""
for key, value in update_data.items():
if key == "endorsed" and value is True:
comment_endorsed.send(sender=sender, user=user, post=comment)
send_signal_after_commit(
lambda: comment_endorsed.send(sender=sender, user=user, post=comment)
)
def create_thread(request, thread_data):
@@ -1502,7 +1513,10 @@ def create_thread(request, thread_data):
raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items())))
serializer.save()
cc_thread = serializer.instance
thread_created.send(sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners)
# Use send_signal_after_commit() to ensure the signal is sent only after the transaction commits.
send_signal_after_commit(
lambda: thread_created.send(sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners)
)
api_thread = serializer.data
_do_extra_actions(api_thread, cc_thread, list(thread_data.keys()), actions_form, context, request)
@@ -1550,7 +1564,9 @@ def create_comment(request, comment_data):
context["cc_requester"].follow(cc_thread)
serializer.save()
cc_comment = serializer.instance
comment_created.send(sender=None, user=request.user, post=cc_comment)
send_signal_after_commit(
lambda: comment_created.send(sender=None, user=request.user, post=cc_comment)
)
api_comment = serializer.data
_do_extra_actions(api_comment, cc_comment, list(comment_data.keys()), actions_form, context, request)
track_comment_created_event(request, course, cc_comment, cc_thread["commentable_id"], followed=False,
@@ -1586,7 +1602,9 @@ def update_thread(request, thread_id, update_data):
if set(update_data) - set(actions_form.fields):
serializer.save()
# signal to update Teams when a user edits a thread
thread_edited.send(sender=None, user=request.user, post=cc_thread)
send_signal_after_commit(
lambda: thread_edited.send(sender=None, user=request.user, post=cc_thread)
)
api_thread = serializer.data
_do_extra_actions(api_thread, cc_thread, list(update_data.keys()), actions_form, context, request)
@@ -1635,7 +1653,9 @@ def update_comment(request, comment_id, update_data):
# Only save comment object if some of the edited fields are in the comment data, not extra actions
if set(update_data) - set(actions_form.fields):
serializer.save()
comment_edited.send(sender=None, user=request.user, post=cc_comment)
send_signal_after_commit(
lambda: comment_edited.send(sender=None, user=request.user, post=cc_comment)
)
api_comment = serializer.data
_do_extra_actions(api_comment, cc_comment, list(update_data.keys()), actions_form, context, request)
_handle_comment_signals(update_data, cc_comment, request.user)
@@ -1823,7 +1843,9 @@ def delete_thread(request, thread_id):
cc_thread, context = _get_thread_and_context(request, thread_id)
if can_delete(cc_thread, context):
cc_thread.delete()
thread_deleted.send(sender=None, user=request.user, post=cc_thread)
send_signal_after_commit(
lambda: thread_deleted.send(sender=None, user=request.user, post=cc_thread)
)
track_thread_deleted_event(request, context["course"], cc_thread)
else:
raise PermissionDenied
@@ -1848,7 +1870,9 @@ def delete_comment(request, comment_id):
cc_comment, context = _get_comment_and_context(request, comment_id)
if can_delete(cc_comment, context):
cc_comment.delete()
comment_deleted.send(sender=None, user=request.user, post=cc_comment)
send_signal_after_commit(
lambda: comment_deleted.send(sender=None, user=request.user, post=cc_comment)
)
track_comment_deleted_event(request, context["course"], cc_comment)
else:
raise PermissionDenied

View File

@@ -273,7 +273,8 @@ class CreateThreadTest(
with self.assert_signal_sent(
api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners")
):
actual = create_thread(self.request, self.minimal_data)
with self.captureOnCommitCallbacks(execute=True):
actual = create_thread(self.request, self.minimal_data)
expected = self.expected_thread_data(
{
"id": "test_id",
@@ -352,7 +353,8 @@ class CreateThreadTest(
with self.assert_signal_sent(
api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners")
):
actual = create_thread(self.request, self.minimal_data)
with self.captureOnCommitCallbacks(execute=True):
actual = create_thread(self.request, self.minimal_data)
expected = self.expected_thread_data(
{
"author_label": "Moderator",
@@ -428,7 +430,8 @@ class CreateThreadTest(
with self.assert_signal_sent(
api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners")
):
create_thread(self.request, data)
with self.captureOnCommitCallbacks(execute=True):
create_thread(self.request, data)
event_name, event_data = mock_emit.call_args[0]
assert event_name == "edx.forum.thread.created"
assert event_data == {
@@ -678,7 +681,8 @@ class CreateCommentTest(
with self.assert_signal_sent(
api, "comment_created", sender=None, user=self.user, exclude_args=("post",)
):
actual = create_comment(self.request, data)
with self.captureOnCommitCallbacks(execute=True):
actual = create_comment(self.request, data)
expected = {
"id": "test_comment",
"thread_id": "test_thread",
@@ -785,7 +789,8 @@ class CreateCommentTest(
with self.assert_signal_sent(
api, "comment_created", sender=None, user=self.user, exclude_args=("post",)
):
actual = create_comment(self.request, data)
with self.captureOnCommitCallbacks(execute=True):
actual = create_comment(self.request, data)
expected = {
"id": "test_comment",
"thread_id": "test_thread",
@@ -1118,9 +1123,10 @@ class UpdateThreadTest(
with self.assert_signal_sent(
api, "thread_edited", sender=None, user=self.user, exclude_args=("post",)
):
actual = update_thread(
self.request, "test_thread", {"raw_body": "Edited body"}
)
with self.captureOnCommitCallbacks(execute=True):
actual = update_thread(
self.request, "test_thread", {"raw_body": "Edited body"}
)
assert actual == self.expected_thread_data(
{
@@ -1436,13 +1442,13 @@ class UpdateThreadTest(
self.register_thread()
data = {"following": new_following}
signal_name = "thread_followed" if new_following else "thread_unfollowed"
mock_path = (
f"openedx.core.djangoapps.django_comment_common.signals.{signal_name}.send"
)
# Patch at the api module level where the signal is imported and used
mock_path = f"lms.djangoapps.discussion.rest_api.api.{signal_name}"
with mock.patch(mock_path) as signal_patch:
result = update_thread(self.request, "test_thread", data)
with self.captureOnCommitCallbacks(execute=True):
result = update_thread(self.request, "test_thread", data)
if old_following != new_following:
self.assertEqual(signal_patch.call_count, 1)
self.assertEqual(signal_patch.send.call_count, 1)
assert result["following"] == new_following
if old_following == new_following:
@@ -1782,9 +1788,10 @@ class UpdateCommentTest(
with self.assert_signal_sent(
api, "comment_edited", sender=None, user=self.user, exclude_args=("post",)
):
actual = update_comment(
self.request, "test_comment", {"raw_body": "Edited body"}
)
with self.captureOnCommitCallbacks(execute=True):
actual = update_comment(
self.request, "test_comment", {"raw_body": "Edited body"}
)
expected = {
"anonymous": False,
"anonymous_to_peers": False,
@@ -2207,7 +2214,7 @@ class UpdateCommentTest(
)
@ddt.unpack
@mock.patch(
"openedx.core.djangoapps.django_comment_common.signals.comment_endorsed.send"
"lms.djangoapps.discussion.rest_api.api.comment_endorsed.send"
)
def test_endorsed_access(
self, role_name, is_thread_author, thread_type, is_comment_author, endorsed_mock
@@ -2226,7 +2233,8 @@ class UpdateCommentTest(
thread_type == "discussion" or not is_thread_author
)
try:
update_comment(self.request, "test_comment", {"endorsed": True})
with self.captureOnCommitCallbacks(execute=True):
update_comment(self.request, "test_comment", {"endorsed": True})
self.assertEqual(endorsed_mock.call_count, 1)
assert not expected_error
except ValidationError as err:
@@ -2354,7 +2362,8 @@ class DeleteThreadTest(
with self.assert_signal_sent(
api, "thread_deleted", sender=None, user=self.user, exclude_args=("post",)
):
assert delete_thread(self.request, self.thread_id) is None
with self.captureOnCommitCallbacks(execute=True):
assert delete_thread(self.request, self.thread_id) is None
self.check_mock_called("delete_thread")
params = {
"thread_id": self.thread_id,
@@ -2540,7 +2549,8 @@ class DeleteCommentTest(
with self.assert_signal_sent(
api, "comment_deleted", sender=None, user=self.user, exclude_args=("post",)
):
assert delete_comment(self.request, self.comment_id) is None
with self.captureOnCommitCallbacks(execute=True):
assert delete_comment(self.request, self.comment_id) is None
self.check_mock_called("delete_comment")
params = {
"comment_id": self.comment_id,

View File

@@ -3,13 +3,14 @@ Utils for discussion API.
"""
import logging
from datetime import datetime
from typing import Dict, List
from typing import Callable, Dict, List
import requests
from crum import get_current_request
from django.conf import settings
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models.functions import Length
from pytz import UTC
@@ -496,3 +497,24 @@ def get_captcha_site_key_by_platform(platform: str) -> str | None:
Get reCAPTCHA site key based on the platform.
"""
return settings.RECAPTCHA_SITE_KEYS.get(platform, None)
def send_signal_after_commit(signal_func: Callable):
"""
Schedule a signal to be sent after the current database transaction commits.
This helper ensures that signals are only sent after the transaction commits,
preventing race conditions where async tasks (like Celery workers) may try to
access database records before they are visible (especially important for MySQL
backend with transaction isolation).
Args:
signal_func: A callable that sends the signal. This will be executed
after the transaction commits.
Example:
send_signal_after_commit(
lambda: thread_created.send(sender=None, user=user, post=thread, notify_all_learners=False)
)
"""
transaction.on_commit(signal_func)

View File

@@ -141,7 +141,6 @@ class TestProctoringDashboardViews(SharedModuleStoreTestCase):
{
'DEFAULT': 'test_proctoring_provider',
'test_proctoring_provider': {},
'proctortrack': {}
},
)
@ddt.data(
@@ -149,9 +148,9 @@ class TestProctoringDashboardViews(SharedModuleStoreTestCase):
('test_proctoring_provider', 'foo@bar.com')
)
@ddt.unpack
def test_non_proctortrack_provider(self, proctoring_provider, escalation_email):
def test_requires_escalation_email_unset(self, proctoring_provider, escalation_email):
"""
Escalation email will not be visible if proctortrack is not the proctoring provider, with or without
Escalation email will not be visible if 'requires_escalation_email' is not set, with or without
escalation email provided.
"""
self.setup_course_with_proctoring_backend(proctoring_provider, escalation_email)
@@ -164,17 +163,16 @@ class TestProctoringDashboardViews(SharedModuleStoreTestCase):
settings.PROCTORING_BACKENDS,
{
'DEFAULT': 'test_proctoring_provider',
'test_proctoring_provider': {},
'proctortrack': {}
'test_proctoring_provider': {"requires_escalation_email": True},
},
)
@patch.dict(settings.FEATURES, {'ENABLE_PROCTORED_EXAMS': True})
def test_proctortrack_provider_with_email(self):
def test_requires_escalation_email_set_with_email(self):
"""
Escalation email will be visible if proctortrack is the proctoring provider, and there
Escalation email will be visible if 'requires_escalation_email' is set, and there
is an escalation email provided.
"""
self.setup_course_with_proctoring_backend('proctortrack', 'foo@bar.com')
self.setup_course_with_proctoring_backend('test_proctoring_provider', 'foo@bar.com')
self.instructor.is_staff = True
self.instructor.save()

View File

@@ -34,6 +34,7 @@ from common.djangoapps.student.roles import (
CourseStaffRole
)
from common.djangoapps.util.json_request import JsonResponse
from common.djangoapps.util.proctoring import requires_escalation_email
from lms.djangoapps.bulk_email.api import is_bulk_email_feature_enabled
from lms.djangoapps.bulk_email.models_api import is_bulk_email_disabled_for_course
from lms.djangoapps.certificates import api as certs_api
@@ -320,7 +321,7 @@ def _section_special_exams(course, access):
else:
# Only call does_backend_support_onboarding if not using an LTI proctoring provider
show_onboarding = does_backend_support_onboarding(course.proctoring_provider)
if proctoring_provider == 'proctortrack':
if requires_escalation_email(course.proctoring_provider):
escalation_email = course.proctoring_escalation_email
from edx_proctoring.api import is_backend_dashboard_available
show_dashboard = is_backend_dashboard_available(course_key)

View File

@@ -3343,14 +3343,6 @@ VIDEO_UPLOAD_PIPELINE = {
'ROOT_PATH': '',
}
### Proctoring configuration (redirct URLs and keys shared between systems) ####
PROCTORING_BACKENDS = {
'DEFAULT': 'null',
# The null key needs to be quoted because
# null is a language independent type in YAML
'null': {}
}
PROCTORED_EXAM_VIEWABLE_PAST_DUE = False
######################### rate limit for yt_video_metadata api ##############

View File

@@ -977,7 +977,7 @@ PRESS_EMAIL: press@example.com
PROCTORING_BACKENDS:
DEFAULT: 'null'
'null': {}
proctortrack:
mockprock:
base_url: hello
client_id: '[encrypted]'
client_secret: '[encrypted]'

View File

@@ -11,7 +11,7 @@ import pytz
</div>
% else:
% if section_data.get('escalation_email'):
<h6 class = "hd hd-6 escalation-email-container">${_('Proctortrack Escalation Email:')} ${ section_data['escalation_email'] }</h6>
<h6 class = "hd hd-6 escalation-email-container">${_('Proctoring Escalation Email:')} ${ section_data['escalation_email'] }</h6>
% endif
<div id="proctoring-accordion">
<div class="wrap">