diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index dd32bcb122..da8476cf57 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -1107,6 +1107,22 @@ class VerificationStatus(models.Model): status="submitted" ).count() + @classmethod + def get_location_id(cls, photo_verification): + """ Return the location id of xblock + + Args: + photo_verification(SoftwareSecurePhotoVerification): SoftwareSecurePhotoVerification object + + Return: + Location Id of xblock if any else empty string + """ + try: + ver_status = cls.objects.filter(checkpoint__photo_verification=photo_verification).latest() + return ver_status.location_id + except cls.DoesNotExist: + return "" + class InCourseReverificationConfiguration(ConfigurationModel): """Configure in-course re-verification. diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index b55e5df0b5..3788c3b3f5 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -772,6 +772,40 @@ class VerificationStatusTest(ModuleStoreTestCase): list(self.check_point2.checkpoint_status.all().values_list('location_id', flat=True)) ) + def test_get_location_id(self): + """ Getting location id for a specific checkpoint """ + + # creating software secure attempt against checkpoint + self.check_point1.add_verification_attempt(SoftwareSecurePhotoVerification.objects.create(user=self.user)) + + # add initial verification status for checkpoint + VerificationStatus.add_verification_status( + checkpoint=self.check_point1, + user=self.user, + status='submitted', + location_id=self.dummy_reverification_item_id_1 + ) + + attempt = SoftwareSecurePhotoVerification.objects.filter(user=self.user) + + self.assertIsNotNone(VerificationStatus.get_location_id(attempt)) + self.assertEqual(VerificationStatus.get_location_id(None), '') + + def test_get_user_attempts(self): + + # adding verification status + VerificationStatus.add_verification_status( + checkpoint=self.check_point1, + user=self.user, + status='submitted', + location_id=self.dummy_reverification_item_id_1 + ) + + self.assertEqual(VerificationStatus.get_user_attempts( + course_key=self.course.id, + user_id=self.user.id, + related_assessment='midterm', location_id=self.dummy_reverification_item_id_1), 1) + class SkippedReverificationTest(ModuleStoreTestCase): """Tests for the SkippedReverification model. """ diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 0042154ea0..0c8cf2baa4 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -9,7 +9,8 @@ from uuid import uuid4 from django.test.utils import override_settings import mock -from mock import patch, Mock +from mock import patch, Mock, ANY +from django.utils import timezone import pytz import ddt from django.test.client import Client @@ -24,8 +25,10 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.django import modulestore from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.factories import check_mongo_calls from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locator import CourseLocator +from microsite_configuration import microsite from openedx.core.djangoapps.user_api.accounts.api import get_account_settings from commerce.tests import TEST_PAYMENT_DATA, TEST_API_URL, TEST_API_SIGNING_KEY @@ -38,16 +41,15 @@ from embargo.test_utils import restrict_course from util.testing import UrlResetMixin from verify_student.views import ( checkout_with_ecommerce_service, - EVENT_NAME_USER_ENTERED_INCOURSE_REVERIFY_VIEW, - EVENT_NAME_USER_SUBMITTED_INCOURSE_REVERIFY, - PayAndVerifyView, - render_to_response, + render_to_response, PayAndVerifyView, EVENT_NAME_USER_ENTERED_INCOURSE_REVERIFY_VIEW, + EVENT_NAME_USER_SUBMITTED_INCOURSE_REVERIFY, _send_email, _compose_message_reverification_email ) from verify_student.models import ( SoftwareSecurePhotoVerification, VerificationCheckpoint, - InCourseReverificationConfiguration + InCourseReverificationConfiguration, VerificationStatus ) from reverification.tests.factories import MidcourseReverificationWindowFactory +from util.date_utils import get_default_time_display def mock_render_to_response(*args, **kwargs): @@ -1531,6 +1533,121 @@ class TestPhotoVerificationResultsCallback(ModuleStoreTestCase): self.assertEquals(response.content, 'OK!') self.assertIsNotNone(CourseEnrollment.objects.get(course_id=self.course_id)) + @mock.patch('verify_student.ssencrypt.has_valid_signature', mock.Mock(side_effect=mocked_has_valid_signature)) + def test_in_course_reverify_disabled(self): + """ + Test for verification passed. + """ + data = { + "EdX-ID": self.receipt_id, + "Result": "PASS", + "Reason": "", + "MessageType": "You have been verified." + } + json_data = json.dumps(data) + response = self.client.post( + reverse('verify_student_results_callback'), data=json_data, + content_type='application/json', + HTTP_AUTHORIZATION='test BBBBBBBBBBBBBBBBBBBB:testing', + HTTP_DATE='testdate' + ) + attempt = SoftwareSecurePhotoVerification.objects.get(receipt_id=self.receipt_id) + self.assertEqual(attempt.status, u'approved') + self.assertEquals(response.content, 'OK!') + # Verify that photo submission confirmation email was sent + self.assertEqual(len(mail.outbox), 0) + user_status = VerificationStatus.objects.filter(user=self.user).count() + self.assertEqual(user_status, 0) + + @mock.patch('verify_student.ssencrypt.has_valid_signature', mock.Mock(side_effect=mocked_has_valid_signature)) + def test_pass_in_course_reverify_result(self): + """ + Test for verification passed. + """ + self.create_reverification_xblock() + + incourse_reverify_enabled = InCourseReverificationConfiguration.current() + incourse_reverify_enabled.enabled = True + incourse_reverify_enabled.save() + + data = { + "EdX-ID": self.receipt_id, + "Result": "PASS", + "Reason": "", + "MessageType": "You have been verified." + } + + json_data = json.dumps(data) + + response = self.client.post( + reverse('verify_student_results_callback'), data=json_data, + content_type='application/json', + HTTP_AUTHORIZATION='test BBBBBBBBBBBBBBBBBBBB:testing', + HTTP_DATE='testdate' + ) + attempt = SoftwareSecurePhotoVerification.objects.get(receipt_id=self.receipt_id) + + self.assertEqual(attempt.status, u'approved') + self.assertEquals(response.content, 'OK!') + # Verify that photo re-verification status email was sent + self.assertEqual(len(mail.outbox), 1) + self.assertEqual("Re-verification Status", mail.outbox[0].subject) + + @mock.patch('verify_student.views._send_email') + @mock.patch('verify_student.ssencrypt.has_valid_signature', mock.Mock(side_effect=mocked_has_valid_signature)) + def test_reverification_on_callback(self, mock_send_email): + """ + Test software secure callback flow for re-verification. + """ + # Create the 'edx-reverification-block' in course tree + self.create_reverification_xblock() + + # create dummy data for software secure photo verification result callback + data = { + "EdX-ID": self.receipt_id, + "Result": "PASS", + "Reason": "", + "MessageType": "You have been verified." + } + json_data = json.dumps(data) + response = self.client.post( + reverse('verify_student_results_callback'), + data=json_data, + content_type='application/json', + HTTP_AUTHORIZATION='test BBBBBBBBBBBBBBBBBBBB:testing', + HTTP_DATE='testdate' + ) + self.assertEqual(response.content, 'OK!') + + # now check that '_send_email' method is called on result callback + # with required parameters + subject = "Re-verification Status" + mock_send_email.assert_called_once_with(self.user.id, subject, ANY) + + def create_reverification_xblock(self): + """ Create the reverification xblock + + """ + # Create checkpoint + checkpoint = VerificationCheckpoint(course_id=self.course_id, checkpoint_name="midterm") + checkpoint.save() + + # Add a re-verification attempt + checkpoint.add_verification_attempt(self.attempt) + + # Create the 'edx-reverification-block' in course tree + section = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section') + subsection = ItemFactory.create(parent=section, category='sequential', display_name='Test Subsection') + vertical = ItemFactory.create(parent=subsection, category='vertical', display_name='Test Unit') + reverification = ItemFactory.create( + parent=vertical, + category='edx-reverification-block', + display_name='Test Verification Block' + ) + + # Add a re-verification attempt status for the user + VerificationStatus.add_verification_status(checkpoint, self.user, "submitted", reverification.location) + class TestReverifyView(ModuleStoreTestCase): """ @@ -1922,3 +2039,273 @@ class TestInCourseReverifyView(ModuleStoreTestCase): "checkpoint_name": checkpoint, "usage_id": unicode(self.reverification_location) }) + + +class TestEmailMessageWithCustomICRVBlock(ModuleStoreTestCase): + """ + Test email sending on re-verification + """ + + def build_course(self): + """ + Build up a course tree with a Reverificaiton xBlock. + """ + # pylint: disable=attribute-defined-outside-init + + self.course_key = SlashSeparatedCourseKey("Robot", "999", "Test_Course") + self.course = CourseFactory.create(org='Robot', number='999', display_name='Test Course') + self.due_date = datetime(2015, 6, 22, tzinfo=pytz.UTC) + + # Create the course modes + for mode in ('audit', 'honor', 'verified'): + min_price = 0 if mode in ["honor", "audit"] else 1 + CourseModeFactory(mode_slug=mode, course_id=self.course_key, min_price=min_price) + + # Create the 'edx-reverification-block' in course tree + section = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section') + subsection = ItemFactory.create(parent=section, category='sequential', display_name='Test Subsection') + vertical = ItemFactory.create(parent=subsection, category='vertical', display_name='Test Unit') + + self.reverification = ItemFactory.create( + parent=vertical, + category='edx-reverification-block', + display_name='Test Verification Block', + metadata={'attempts': 3, 'due': self.due_date} + ) + + self.section_location = section.location + self.subsection_location = subsection.location + self.vertical_location = vertical.location + self.reverification_location = self.reverification.location + self.assessment = "midterm" + + self.re_verification_link = reverse( + 'verify_student_incourse_reverify', + args=( + unicode(self.course_key), + unicode(self.assessment), + unicode(self.reverification_location) + ) + ) + + def setUp(self): + super(TestEmailMessageWithCustomICRVBlock, self).setUp() + self.build_course() + self.check_point = VerificationCheckpoint.objects.create( + course_id=self.course.id, checkpoint_name=self.assessment + ) + + self.check_point.add_verification_attempt(SoftwareSecurePhotoVerification.objects.create(user=self.user)) + + VerificationStatus.add_verification_status( + checkpoint=self.check_point, + user=self.user, + status='submitted', + location_id=self.reverification_location + ) + + self.attempt = SoftwareSecurePhotoVerification.objects.filter(user=self.user) + + def test_approved_email_message(self): + + subject, body = _compose_message_reverification_email( + self.course.id, self.user.id, "midterm", self.attempt, "approved", True + ) + + self.assertIn( + "Your verification for course {course_name} and assessment {assessment} has been passed.".format( + course_name=self.course.display_name_with_default, + assessment=self.assessment + ), + body + ) + + self.assertIn("Re-verification Status", subject) + + def test_denied_email_message_with_valid_due_date_and_attempts_allowed(self): + + __, body = _compose_message_reverification_email( + self.course.id, self.user.id, "midterm", self.attempt, "denied", True + ) + + self.assertIn( + "Your verification for course {course_name} and assessment {assessment} has failed.".format( + course_name=self.course.display_name_with_default, + assessment=self.assessment + ), + body + ) + + self.assertIn("Assessment closes on {due_date}".format(due_date=get_default_time_display(self.due_date)), body) + self.assertIn("Click on link below to re-verify", body) + self.assertIn( + "https://{}{}".format( + microsite.get_value('SITE_NAME', 'localhost'), self.re_verification_link + ), + body + ) + + def test_denied_email_message_with_close_verification_dates(self): + + return_value = datetime(2016, 1, 1, tzinfo=timezone.utc) + with patch.object(timezone, 'now', return_value=return_value): + __, body = _compose_message_reverification_email( + self.course.id, self.user.id, "midterm", self.attempt, "denied", True + ) + + self.assertIn( + "Your verification for course {course_name} and assessment {assessment} has failed.".format( + course_name=self.course.display_name_with_default, + assessment=self.assessment + ), + body + ) + + self.assertIn("Assessment date has passed and retake not allowed", body) + + def test_check_num_queries(self): + # Get the re-verification block to check the call made + with check_mongo_calls(2): + ver_block = modulestore().get_item(self.reverification_location) + + # Expect that the verification block is fetched + self.assertIsNotNone(ver_block) + + +class TestEmailMessageWithDefaultICRVBlock(ModuleStoreTestCase): + """ + Test for In-course Re-verification + """ + + def build_course(self): + """ + Build up a course tree with a Reverificaiton xBlock. + """ + # pylint: disable=attribute-defined-outside-init + + self.course_key = SlashSeparatedCourseKey("Robot", "999", "Test_Course") + self.course = CourseFactory.create(org='Robot', number='999', display_name='Test Course') + + # Create the course modes + for mode in ('audit', 'honor', 'verified'): + min_price = 0 if mode in ["honor", "audit"] else 1 + CourseModeFactory(mode_slug=mode, course_id=self.course_key, min_price=min_price) + + # Create the 'edx-reverification-block' in course tree + section = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section') + subsection = ItemFactory.create(parent=section, category='sequential', display_name='Test Subsection') + vertical = ItemFactory.create(parent=subsection, category='vertical', display_name='Test Unit') + + self.reverification = ItemFactory.create( + parent=vertical, + category='edx-reverification-block', + display_name='Test Verification Block' + ) + + self.section_location = section.location + self.subsection_location = subsection.location + self.vertical_location = vertical.location + self.reverification_location = self.reverification.location + self.assessment = "midterm" + + self.re_verification_link = reverse( + 'verify_student_incourse_reverify', + args=( + unicode(self.course_key), + unicode(self.assessment), + unicode(self.reverification_location) + ) + ) + + def setUp(self): + super(TestEmailMessageWithDefaultICRVBlock, self).setUp() + + self.build_course() + self.check_point = VerificationCheckpoint.objects.create( + course_id=self.course.id, checkpoint_name=self.assessment + ) + self.check_point.add_verification_attempt(SoftwareSecurePhotoVerification.objects.create(user=self.user)) + self.attempt = SoftwareSecurePhotoVerification.objects.filter(user=self.user) + + def test_denied_email_message_with_no_attempt_allowed(self): + + VerificationStatus.add_verification_status( + checkpoint=self.check_point, + user=self.user, + status='submitted', + location_id=self.reverification_location + ) + + __, body = _compose_message_reverification_email( + self.course.id, self.user.id, "midterm", self.attempt, "denied", True + ) + + self.assertIn( + "Your verification for course {course_name} and assessment {assessment} has failed.".format( + course_name=self.course.display_name_with_default, + assessment=self.assessment + ), + body + ) + + self.assertIn("You have reached your allowed attempts limit. No more retakes allowed.", body) + + def test_due_date(self): + self.reverification.due = datetime.now() + self.reverification.save() + + VerificationStatus.add_verification_status( + checkpoint=self.check_point, + user=self.user, + status='submitted', + location_id=self.reverification_location + ) + __, body = _compose_message_reverification_email( + self.course.id, self.user.id, "midterm", self.attempt, "denied", True + ) + + self.assertIn( + "Your verification for course {course_name} and assessment {assessment} has failed.".format( + course_name=self.course.display_name_with_default, + assessment=self.assessment + ), + body + ) + + self.assertIn("You have reached your allowed attempts limit. No more retakes allowed.", body) + + def test_denied_email_message_with_no_due_date(self): + + VerificationStatus.add_verification_status( + checkpoint=self.check_point, + user=self.user, + status='error', + location_id=self.reverification_location + ) + + __, body = _compose_message_reverification_email( + self.course.id, self.user.id, "midterm", self.attempt, "denied", True + ) + + self.assertIn( + "Your verification for course {course_name} and assessment {assessment} has failed.".format( + course_name=self.course.display_name_with_default, + assessment=self.assessment + ), + body + ) + + self.assertIn("Assessment is open and you have 1 attempt(s) remaining.", body) + self.assertIn("Click on link below to re-verify", body) + self.assertIn( + "https://{}{}".format( + microsite.get_value('SITE_NAME', 'localhost'), self.re_verification_link + ), + body + ) + + def test_error_on_compose_email(self): + resp = _compose_message_reverification_email( + self.course.id, self.user.id, "midterm", self.attempt, "denied", True + ) + self.assertIsNone(resp) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 4683837634..7d6fe5fdd3 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -10,6 +10,7 @@ from collections import namedtuple from pytz import UTC +from django.utils import timezone from ipware.ip import get_ip from django.conf import settings from django.core.urlresolvers import reverse @@ -58,6 +59,7 @@ from util.date_utils import get_default_time_display from eventtracking import tracker import analytics from courseware.url_helpers import get_redirect_url +from django.contrib.auth.models import User log = logging.getLogger(__name__) @@ -859,6 +861,96 @@ def submit_photos_for_verification(request): return HttpResponse(200) +def _compose_message_reverification_email( + course_key, user_id, relates_assessment, photo_verification, status, is_secure +): # pylint: disable=invalid-name + """ Composes subject and message for email + + Args: + course_key(CourseKey): CourseKey object + user_id(str): User Id + relates_assessment(str): related assessment name + photo_verification(QuerySet/SoftwareSecure): A query set of SoftwareSecure objects or SoftwareSecure objec + status(str): approval status + is_secure(Bool): Is running on secure protocol or not + + Returns: + None if any error occurred else Tuple of subject and message strings + """ + try: + location_id = VerificationStatus.get_location_id(photo_verification) + usage_key = UsageKey.from_string(location_id) + course = modulestore().get_course(course_key) + redirect_url = get_redirect_url(course_key, usage_key.replace(course_key=course_key)) + + subject = "Re-verification Status" + + context = { + "status": status, + "course_name": course.display_name_with_default, + "assessment": relates_assessment, + "courseware_url": redirect_url + } + + reverification_block = modulestore().get_item(usage_key) + # Allowed attempts is 1 if not set on verification block + allowed_attempts = 1 if reverification_block.attempts == 0 else reverification_block.attempts + user_attempts = VerificationStatus.get_user_attempts(user_id, course_key, relates_assessment, location_id) + left_attempts = allowed_attempts - user_attempts + is_attempt_allowed = left_attempts > 0 + verification_open = True + if reverification_block.due: + verification_open = timezone.now() <= reverification_block.due + + context["left_attempts"] = left_attempts + context["is_attempt_allowed"] = is_attempt_allowed + context["verification_open"] = verification_open + context["due_date"] = get_default_time_display(reverification_block.due) + context["is_secure"] = is_secure + context["site"] = microsite.get_value('SITE_NAME', 'localhost') + context['platform_name'] = microsite.get_value('platform_name', settings.PLATFORM_NAME), + + re_verification_link = reverse( + 'verify_student_incourse_reverify', + args=( + unicode(course_key), + unicode(relates_assessment), + unicode(location_id) + ) + ) + context["reverify_link"] = re_verification_link + message = render_to_string('emails/reverification_processed.txt', context) + log.info( + "Sending email to User_Id=%s. Attempts left for this user are %s. " + "Allowed attempts %s. " + "Due Date %s", + str(user_id), left_attempts, allowed_attempts, str(reverification_block.due) + ) + return subject, message + # Catch all exception to avoid raising back to view + except: # pylint: disable=bare-except + log.exception("The email for re-verification sending failed for user_id %s", user_id) + + +def _send_email(user_id, subject, message): + """ Send email to given user + + Args: + user_id(str): User Id + subject(str): Subject lines of emails + message(str): Email message body + + Returns: + None + """ + from_address = microsite.get_value( + 'email_from_address', + settings.DEFAULT_FROM_EMAIL + ) + user = User.objects.get(id=user_id) + user.email_user(subject, message, from_address) + + @require_POST @csrf_exempt # SS does its own message signing, and their API won't have a cookie value def results_callback(request): @@ -910,26 +1002,24 @@ def results_callback(request): try: attempt = SoftwareSecurePhotoVerification.objects.get(receipt_id=receipt_id) except SoftwareSecurePhotoVerification.DoesNotExist: - log.error("Software Secure posted back for receipt_id {}, but not found".format(receipt_id)) + log.error("Software Secure posted back for receipt_id %s, but not found", receipt_id) return HttpResponseBadRequest("edX ID {} not found".format(receipt_id)) - checkpoints = VerificationCheckpoint.objects.filter(photo_verification=attempt).all() - if result == "PASS": - log.debug("Approving verification for {}".format(receipt_id)) + log.debug("Approving verification for %s", receipt_id) attempt.approve() status = "approved" elif result == "FAIL": - log.debug("Denying verification for {}".format(receipt_id)) + log.debug("Denying verification for %s", receipt_id) attempt.deny(json.dumps(reason), error_code=error_code) status = "denied" elif result == "SYSTEM FAIL": - log.debug("System failure for {} -- resetting to must_retry".format(receipt_id)) + log.debug("System failure for %s -- resetting to must_retry", receipt_id) attempt.system_error(json.dumps(reason), error_code=error_code) status = "error" log.error("Software Secure callback attempt for %s failed: %s", receipt_id, reason) else: - log.error("Software Secure returned unknown result {}".format(result)) + log.error("Software Secure returned unknown result %s", result) return HttpResponseBadRequest( "Result {} not understood. Known results: PASS, FAIL, SYSTEM FAIL".format(result) ) @@ -939,7 +1029,22 @@ def results_callback(request): course_id = attempt.window.course_id course_enrollment = CourseEnrollment.get_or_create_enrollment(attempt.user, course_id) course_enrollment.emit_event(EVENT_NAME_USER_REVERIFICATION_REVIEWED_BY_SOFTWARESECURE) - VerificationStatus.add_status_from_checkpoints(checkpoints=checkpoints, user=attempt.user, status=status) + + incourse_reverify_enabled = InCourseReverificationConfiguration.current().enabled + if incourse_reverify_enabled: + checkpoints = VerificationCheckpoint.objects.filter(photo_verification=attempt).all() + VerificationStatus.add_status_from_checkpoints(checkpoints=checkpoints, user=attempt.user, status=status) + # If this is re-verification then send the update email + if checkpoints: + user_id = attempt.user.id + course_key = checkpoints[0].course_id + relates_assessment = checkpoints[0].checkpoint_name + + subject, message = _compose_message_reverification_email( + course_key, user_id, relates_assessment, attempt, status, request.is_secure() + ) + + _send_email(user_id, subject, message) return HttpResponse("OK!") diff --git a/lms/templates/emails/reverification_processed.txt b/lms/templates/emails/reverification_processed.txt new file mode 100644 index 0000000000..762b2b9268 --- /dev/null +++ b/lms/templates/emails/reverification_processed.txt @@ -0,0 +1,43 @@ +<%namespace file="../main.html" import="stanford_theme_enabled" /> +<%! from django.utils.translation import ugettext as _ %> +% if status == "approved": + ${_("Your verification for course {course_name} and assessment {assessment} " + "has been passed." + ).format(course_name=course_name, assessment=assessment)} + +%else: + ${_("Your verification for course {course_name} and assessment {assessment} " + "has failed." + ).format(course_name=course_name, assessment=assessment)} + + % if not is_attempt_allowed: + ${_("You have reached your allowed attempts limit. No more retakes allowed.")} + % elif not verification_open: + ${_("Assessment date has passed and retake not allowed.")} + % else: + % if due_date: + ${_("Assessment closes on {due_date}.".format(due_date=due_date))} + % else: + ${_("Assessment is open and you have {left_attempts} attempt(s) remaining.".format(left_attempts=left_attempts))} + % endif + + ${_("Click on link below to re-verify:")} + % if is_secure: + https://${ site }${ reverify_link } + % else: + http://${ site }${ reverify_link } + % endif + + % endif +% endif + + ${_("Click on link below to go to the courseware:")} + % if is_secure: + https://${ site }${ courseware_url } + % else: + http://${ site }${ courseware_url } + % endif + + + +${_("The {platform_name} Team.").format(platform_name=platform_name)}