diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index dbfc8a1faf75140c5122ecd763a2fbe6494ab6ed..6c109631121442fa0115346a86be40c3890e7eb4 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -8,7 +8,7 @@ import ddt import json import itertools import unittest -from datetime import datetime +from datetime import datetime, timedelta from HTMLParser import HTMLParser from nose.plugins.attrib import attr @@ -32,6 +32,7 @@ from certificates import api as certs_api from certificates.models import CertificateStatuses, CertificateGenerationConfiguration from certificates.tests.factories import GeneratedCertificateFactory from course_modes.models import CourseMode +from course_modes.tests.factories import CourseModeFactory from courseware.model_data import set_score from courseware.testutils import RenderXBlockTestMixin from courseware.tests.factories import StudentModuleFactory @@ -190,7 +191,8 @@ class ViewsTestCase(ModuleStoreTestCase): self.component = ItemFactory.create(category='problem', parent_location=self.vertical.location) self.course_key = self.course.id - self.user = UserFactory(username='dummy', password='123456', email='test@mit.edu') + self.password = '123456' + self.user = UserFactory(username='dummy', password=self.password, email='test@mit.edu') self.date = datetime(2013, 1, 22, tzinfo=UTC) self.enrollment = CourseEnrollment.enroll(self.user, self.course_key) self.enrollment.created = self.date @@ -270,7 +272,7 @@ class ViewsTestCase(ModuleStoreTestCase): self.section.location.name, 'f' ]) - self.client.login(username=self.user.username, password="123456") + self.client.login(username=self.user.username, password=self.password) response = self.client.get(request_url) self.assertEqual(response.status_code, 404) @@ -283,7 +285,7 @@ class ViewsTestCase(ModuleStoreTestCase): self.section.location.name, '1' ] - self.client.login(username=self.user.username, password="123456") + self.client.login(username=self.user.username, password=self.password) for idx, val in enumerate(url_parts): url_parts_copy = url_parts[:] url_parts_copy[idx] = val + u'χ' @@ -458,6 +460,136 @@ class ViewsTestCase(ModuleStoreTestCase): # Verify that the email opt-in checkbox does not appear self.assertNotContains(response, checkbox_html, html=True) + def test_financial_assistance_page(self): + self.client.login(username=self.user.username, password=self.password) + url = reverse('financial_assistance') + response = self.client.get(url) + # This is a static page, so just assert that it is returned correctly + self.assertEqual(response.status_code, 200) + self.assertIn('Financial Assistance Application', response.content) + + def test_financial_assistance_form(self): + non_verified_course = CourseFactory.create().id + verified_course_verified_track = CourseFactory.create().id + verified_course_audit_track = CourseFactory.create().id + verified_course_deadline_passed = CourseFactory.create().id + unenrolled_course = CourseFactory.create().id + + enrollments = ( + (non_verified_course, CourseMode.AUDIT, None), + (verified_course_verified_track, CourseMode.VERIFIED, None), + (verified_course_audit_track, CourseMode.AUDIT, None), + (verified_course_deadline_passed, CourseMode.AUDIT, datetime.now(UTC) - timedelta(days=1)) + ) + for course, mode, expiration in enrollments: + CourseModeFactory(mode_slug=CourseMode.AUDIT, course_id=course) + if course != non_verified_course: + CourseModeFactory(mode_slug=CourseMode.VERIFIED, course_id=course, expiration_datetime=expiration) + CourseEnrollmentFactory(course_id=course, user=self.user, mode=mode) + + self.client.login(username=self.user.username, password=self.password) + url = reverse('financial_assistance_form') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # Ensure that the user can only apply for assistance in + # courses which have a verified mode which hasn't expired yet, + # where the user is not already enrolled in verified mode + self.assertIn(str(verified_course_audit_track), response.content) + for course in ( + non_verified_course, + verified_course_verified_track, + verified_course_deadline_passed, + unenrolled_course + ): + self.assertNotIn(str(course), response.content) + + def _submit_financial_assistance_form(self, data): + """Submit a financial assistance request.""" + self.client.login(username=self.user.username, password=self.password) + url = reverse('submit_financial_assistance_request') + return self.client.post(url, json.dumps(data), content_type='application/json') + + @patch.object(views, '_record_feedback_in_zendesk') + def test_submit_financial_assistance_request(self, mock_record_feedback): + username = self.user.username + course = unicode(self.course_key) + legal_name = 'Jesse Pinkman' + country = 'United States' + income = '1234567890' + reason_for_applying = "It's just basic chemistry, yo." + goals = "I don't know if it even matters, but... work with my hands, I guess." + effort = "I'm done, okay? You just give me my money, and you and I, we're done." + data = { + 'username': username, + 'course_id': course, + 'legal_name': legal_name, + 'email': self.user.email, + 'country': country, + 'income': income, + 'reason_for_applying': reason_for_applying, + 'goals': goals, + 'effort': effort, + 'marketing_permission': False, + } + response = self._submit_financial_assistance_form(data) + self.assertEqual(response.status_code, 204) + + __, ___, ticket_subject, ticket_body, tags, additional_info = mock_record_feedback.call_args[0] + for info in (country, income, reason_for_applying, goals, effort): + self.assertIn(info, ticket_body) + self.assertIn('This user HAS NOT allowed this content to be used for edX marketing purposes.', ticket_body) + + self.assertEqual( + ticket_subject, + 'Financial assistance request for user {username} in course {course}'.format( + username=username, + course=course + ) + ) + self.assertDictContainsSubset( + { + 'issue_type': 'Financial Assistance', + 'course_id': course + }, + tags + ) + self.assertIn('Client IP', additional_info) + + @patch.object(views, '_record_feedback_in_zendesk', return_value=False) + def test_zendesk_submission_failed(self, _mock_record_feedback): + response = self._submit_financial_assistance_form({ + 'username': self.user.username, + 'course_id': '', + 'legal_name': '', + 'email': '', + 'country': '', + 'income': '', + 'reason_for_applying': '', + 'goals': '', + 'effort': '', + 'marketing_permission': False, + }) + self.assertEqual(response.status_code, 500) + + @ddt.data( + ({}, 400), + ({'username': 'wwhite'}, 403) + ) + @ddt.unpack + def test_submit_financial_assistance_errors(self, data, status): + response = self._submit_financial_assistance_form(data) + self.assertEqual(response.status_code, status) + + def test_financial_assistance_login_required(self): + for url in ( + reverse('financial_assistance'), + reverse('financial_assistance_form'), + reverse('submit_financial_assistance_request') + ): + response = self.client.get(url) + self.assertRedirects(response, reverse('signin_user') + '?next=' + url) + @attr('shard_1') # setting TIME_ZONE_DISPLAYED_FOR_DEADLINES explicitly diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 34c88ec76b6b45a51b8bb5f994421922ce841629..6c2ecf1ce3bfc8972018de2d6a3a4b2f2e07d483 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -3,8 +3,9 @@ Courseware views functions """ import logging -import urllib import json +import textwrap +import urllib from datetime import datetime from django.utils.translation import ugettext as _ @@ -16,15 +17,18 @@ from django.core.urlresolvers import reverse from django.contrib.auth.models import User, AnonymousUser from django.contrib.auth.decorators import login_required from django.db import transaction +from django.db.models import Q from django.utils.timezone import UTC from django.views.decorators.http import require_GET, require_POST, require_http_methods -from django.http import Http404, HttpResponse, HttpResponseBadRequest +from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.shortcuts import redirect from certificates import api as certs_api from edxmako.shortcuts import render_to_response, render_to_string, marketing_link from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.cache import cache_control +from ipware.ip import get_ip from markupsafe import escape +from rest_framework import status from courseware import grades from courseware.access import has_access, _adjust_start_date_for_beta_testers @@ -72,6 +76,7 @@ from shoppingcart.models import CourseRegistrationCode from shoppingcart.utils import is_shopping_cart_enabled from opaque_keys import InvalidKeyError from util.milestones_helpers import get_prerequisite_courses_display +from util.views import _record_feedback_in_zendesk from microsite_configuration import microsite from opaque_keys.edx.locations import SlashSeparatedCourseKey @@ -1404,3 +1409,229 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True): 'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'), } return render_to_response('courseware/courseware-chromeless.html', context) + + +# Translators: "percent_sign" is the symbol "%". "platform_name" is a +# string identifying the name of this installation, such as "edX". +FINANCIAL_ASSISTANCE_HEADER = _( + '{platform_name} now offers financial assistance for learners who want to earn verified certificates but' + ' who may not be able to pay the Verified Certificate fee. Eligible learners receive 90{percent_sign} off' + ' the Verified Certificate fee for a course.\nTo apply for financial assistance, enroll in the' + ' audit track for a course that offers Verified Certificates, and then complete this application.' + ' Note that you must complete a separate application for each course you take.' +).format( + percent_sign="%", + platform_name=settings.PLATFORM_NAME +).split('\n') + + +FA_INCOME_LABEL = _('Annual Income') +FA_REASON_FOR_APPLYING_LABEL = _( + 'Tell us about your current financial situation, including any unusual circumstances.' +) +FA_GOALS_LABEL = _( + 'Tell us about your learning or professional goals. How will a Verified Certificate in' + ' this course help you achieve these goals?' +) +FA_EFFORT_LABEL = _( + 'Tell us about your plans for this course. What steps will you take to help you complete' + ' the course work a receive a certificate?' +) +FA_SHORT_ANSWER_INSTRUCTIONS = _('Use between 250 and 500 words or so in your response.') + + +@login_required +def financial_assistance(_request): + """Render the initial financial assistance page.""" + return render_to_response('financial-assistance/financial-assistance.html', { + 'header_text': FINANCIAL_ASSISTANCE_HEADER + }) + + +@login_required +@require_POST +def financial_assistance_request(request): + """Submit a request for financial assistance to Zendesk.""" + try: + data = json.loads(request.body) + # Simple sanity check that the session belongs to the user + # submitting an FA request + username = data['username'] + if request.user.username != username: + return HttpResponseForbidden() + + course_id = data['course_id'] + legal_name = data['legal_name'] + email = data['email'] + country = data['country'] + income = data['income'] + reason_for_applying = data['reason_for_applying'] + goals = data['goals'] + effort = data['effort'] + marketing_permission = data['marketing_permission'] + ip_address = get_ip(request) + except ValueError: + # Thrown if JSON parsing fails + return HttpResponseBadRequest('Could not parse request JSON.') + except KeyError as err: + # Thrown if fields are missing + return HttpResponseBadRequest('The field {} is required.'.format(err.message)) + + ticket_body = textwrap.dedent( + ''' + Annual Income: {income} + Country: {country} + + {reason_label} + {separator} + {reason_for_applying} + + {goals_label} + {separator} + {goals} + + {effort_label} + {separator} + {effort} + + This user {allowed_for_marketing} allowed this content to be used for edX marketing purposes. + '''.format( + income=income, + country=country, + reason_label=FA_REASON_FOR_APPLYING_LABEL, + reason_for_applying=reason_for_applying, + goals_label=FA_GOALS_LABEL, + goals=goals, + effort_label=FA_EFFORT_LABEL, + effort=effort, + allowed_for_marketing='HAS' if marketing_permission else 'HAS NOT', + separator='=' * 16 + ) + ) + + zendesk_submitted = _record_feedback_in_zendesk( + legal_name, + email, + 'Financial assistance request for user {username} in course {course_id}'.format( + username=username, + course_id=course_id + ), + ticket_body, + {'issue_type': 'Financial Assistance', 'course_id': course_id}, + {'Client IP': ip_address} + ) + + if not zendesk_submitted: + # The call to Zendesk failed. The frontend will display a + # message to the user. + return HttpResponse(status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return HttpResponse(status=status.HTTP_204_NO_CONTENT) + + +@login_required +def financial_assistance_form(request): + """Render the financial assistance application form page.""" + user = request.user + enrolled_courses = [ + {'name': enrollment.course_overview.display_name, 'value': unicode(enrollment.course_id)} + for enrollment in CourseEnrollment.enrollments_for_user(user).order_by('-created') + if CourseMode.objects.filter( + Q(expiration_datetime__isnull=True) | Q(expiration_datetime__gt=datetime.now(UTC())), + course_id=enrollment.course_id, + mode_slug=CourseMode.VERIFIED + ).exists() + and enrollment.mode != CourseMode.VERIFIED + ] + return render_to_response('financial-assistance/apply.html', { + 'header_text': FINANCIAL_ASSISTANCE_HEADER, + 'student_faq_url': marketing_link('FAQ'), + 'dashboard_url': reverse('dashboard'), + 'platform_name': settings.PLATFORM_NAME, + 'user_details': { + 'email': user.email, + 'username': user.username, + 'name': user.profile.name, + 'country': str(user.profile.country.name), + }, + 'submit_url': reverse('submit_financial_assistance_request'), + 'fields': [ + { + 'name': 'course', + 'type': 'select', + 'label': _('Course'), + 'placeholder': '', + 'defaultValue': '', + 'required': True, + 'options': enrolled_courses, + 'instructions': _( + 'Select the course for which you want to earn a verified certificate. If' + ' the course does not appear in the list, make sure that you have enrolled' + ' in the audit track for the course.' + ) + }, + { + 'name': 'income', + 'type': 'text', + 'label': FA_INCOME_LABEL, + 'placeholder': _('income in USD ($)'), + 'defaultValue': '', + 'required': True, + 'restrictions': {}, + 'instructions': _('Specify your annual income in USD.') + }, + { + 'name': 'reason_for_applying', + 'type': 'textarea', + 'label': FA_REASON_FOR_APPLYING_LABEL, + 'placeholder': '', + 'defaultValue': '', + 'required': True, + 'restrictions': { + 'min_length': settings.FINANCIAL_ASSISTANCE_MIN_LENGTH, + 'max_length': settings.FINANCIAL_ASSISTANCE_MAX_LENGTH + }, + 'instructions': FA_SHORT_ANSWER_INSTRUCTIONS + }, + { + 'name': 'goals', + 'type': 'textarea', + 'label': FA_GOALS_LABEL, + 'placeholder': '', + 'defaultValue': '', + 'required': True, + 'restrictions': { + 'min_length': settings.FINANCIAL_ASSISTANCE_MIN_LENGTH, + 'max_length': settings.FINANCIAL_ASSISTANCE_MAX_LENGTH + }, + 'instructions': FA_SHORT_ANSWER_INSTRUCTIONS + }, + { + 'name': 'effort', + 'type': 'textarea', + 'label': FA_EFFORT_LABEL, + 'placeholder': '', + 'defaultValue': '', + 'required': True, + 'restrictions': { + 'min_length': settings.FINANCIAL_ASSISTANCE_MIN_LENGTH, + 'max_length': settings.FINANCIAL_ASSISTANCE_MAX_LENGTH + }, + 'instructions': FA_SHORT_ANSWER_INSTRUCTIONS + }, + { + 'placeholder': '', + 'name': 'mktg-permission', + 'label': _( + 'I allow edX to use the information provided in this application for edX marketing purposes.' + ), + 'defaultValue': '', + 'type': 'checkbox', + 'required': False, + 'instructions': _( + 'Annual income and personal information such as email address will not be shared.' + ), + 'restrictions': {} + } + ], + }) diff --git a/lms/envs/common.py b/lms/envs/common.py index 176659cf0a4a0f250e33066941b9e9529d054902..96fc5a97d6eef0ed9c927d343e2e3a0aa2ace358 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2732,3 +2732,10 @@ PROCTORING_SETTINGS = {} # The reason we introcuced this number is because we do not want the CCX # to compete with the MOOC. CCX_MAX_STUDENTS_ALLOWED = 200 + +# Financial assistance settings + +# Maximum and minimum length of answers, in characters, for the +# financial assistance form +FINANCIAL_ASSISTANCE_MIN_LENGTH = 800 +FINANCIAL_ASSISTANCE_MAX_LENGTH = 2500 diff --git a/lms/envs/test.py b/lms/envs/test.py index 44e4cbb2566023c172a7678a4abff0b9fc570d29..d16b022028162adbbdb9f9f605cc9d067d8e8703 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -549,3 +549,6 @@ AUTHENTICATION_BACKENDS += ('lti_provider.users.LtiBackend',) # ORGANIZATIONS FEATURES['ORGANIZATIONS_APP'] = True + +# Financial assistance page +FEATURES['ENABLE_FINANCIAL_ASSISTANCE_FORM'] = True diff --git a/lms/static/sass/_build-lms.scss b/lms/static/sass/_build-lms.scss index 8787a7b4d56393543b991bdd182a2e0b3c9046cd..9d06bd3fecdca2e8c6c6c480ea72c3c7d04628b8 100644 --- a/lms/static/sass/_build-lms.scss +++ b/lms/static/sass/_build-lms.scss @@ -53,6 +53,7 @@ @import 'views/shoppingcart'; @import 'views/homepage'; @import 'views/support'; +@import "views/financial-assistance"; @import 'course/auto-cert'; // app - discussion diff --git a/lms/static/sass/views/_financial-assistance.scss b/lms/static/sass/views/_financial-assistance.scss new file mode 100644 index 0000000000000000000000000000000000000000..4fc5bc69bdb8267e10dd1e7a25c7d6a70ceee467 --- /dev/null +++ b/lms/static/sass/views/_financial-assistance.scss @@ -0,0 +1,76 @@ +.financial-assistance-wrapper { + margin: auto; + padding: $baseline 0; + max-width: 1180px; + + .financial-assistance { + border-bottom: 4px solid $gray-l5; + + h1 { + @extend %t-title4; + @include text-align(left); + margin: 0; + padding: ($baseline/2) 0; + border-bottom: 4px solid $gray-l5; + color: $m-gray-d3; + } + + h2 { + @extend %t-title6; + @extend %t-strong; + margin-top: ($baseline/2); + text-transform: none; + } + + p { + @extend %t-copy-base; + padding: ($baseline/2) 0; + margin: 0; + color: $m-gray-d2; + } + + .apply-form-list { + padding: 0; + list-style: none; + + .apply-form-section { + border-bottom: 2px solid $gray-l5; + } + + .apply-form-section:last-child { + border: none; + } + + .about-me { + padding: 0; + list-style: none; + + .about-me-item { + @include margin-right(150px); + display: inline-block; + + p { + padding: 0; + display: block; + } + } + } + } + } + + .financial-assistance-footer { + padding: $baseline; + + .faq-link { + padding: $baseline/2; + } + + .action-link { + @include float(right); + padding: $baseline/2; + background-color: $m-blue-d2; + color: $gray-l7; + border-radius: 2px; + } + } +} diff --git a/lms/templates/financial-assistance/apply.html b/lms/templates/financial-assistance/apply.html new file mode 100644 index 0000000000000000000000000000000000000000..7b8af4cdf6ba1d2998f10ce177e78230a996a1cb --- /dev/null +++ b/lms/templates/financial-assistance/apply.html @@ -0,0 +1,23 @@ +<%inherit file="../main.html"/> +<%! +import json + +from openedx.core.lib.js_utils import escape_json_dumps +%> +<%namespace name='static' file='/static_content.html'/> + +<%block name="js_extra"> +<%static:require_module module_name="js/financial-assistance/financial_assistance_form_factory" class_name="FinancialAssistanceFactory"> +FinancialAssistanceFactory({ + fields: ${escape_json_dumps(fields)}, + user_details: ${escape_json_dumps(user_details)}, + header_text: ${escape_json_dumps(header_text)}, + student_faq_url: ${json.dumps(student_faq_url)}, + dashboard_url: ${json.dumps(dashboard_url)}, + platform_name: ${escape_json_dumps(platform_name)}, + submit_url: ${json.dumps(submit_url)} +}); +</%static:require_module> +</%block> + +<div class="financial-assistance-wrapper"></div> diff --git a/lms/templates/financial-assistance/financial-assistance.html b/lms/templates/financial-assistance/financial-assistance.html new file mode 100644 index 0000000000000000000000000000000000000000..e611f8453b4d1e5ae609150d5561e0e12449c50e --- /dev/null +++ b/lms/templates/financial-assistance/financial-assistance.html @@ -0,0 +1,38 @@ +<%inherit file="../main.html"/> +<% +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ + +from edxmako.shortcuts import marketing_link +%> + +<div class="financial-assistance-wrapper"> + + <div class="financial-assistance financial-assistance-header"> + <h1>${_("Financial Assistance Application")}</h1> + % for line in header_text: + <p>${line}</p> + % endfor + </div> + + <div class="financial-assistance financial-assistance-body"> + <h2>${_("A Note to Learners")}</h2> + <p>${_("Dear edX Learner,")}</p> + <p>${_("EdX Financial Assistance is a program we created to give learners in all financial circumstances a chance to earn a Verified Certificate upon successful completion of an edX course.")}</p> + <p>${_("If you are interested in working toward a Verified Certificate, but cannot afford to pay the fee, please apply now. Please note space is limited.")}</p> + <p>${_("In order to be eligible for edX Financial Assistance, you must demonstrate that paying the Verified Certificate fee would cause you economic hardship. To apply, you will be asked to answer a few questions about why you are applying and how the Verified Certificate will benefit you.")}</p> + <p>${_("Once your application is approved, we'll email to let you know and give you instructions for how to verify your identity on edX.org; then you can start working toward completing your edX course.")}</p> + <p>${_("EdX is committed to making it possible for you to take high quality courses from leading institutions regardless of your financial situation, earn a Verified Certificate, and share your success with others.")}</p> + <p class="signature">${_("Sincerely, Anant")}</p> + </div> + + <div class="financial-assistance-footer"> + <% + faq_link = marketing_link('FAQ') + %> + % if faq_link != '#': + <a class="faq-link" href="${faq_link}">${_("Back to Student FAQs")}</a> + % endif + <a class="action-link" href="${reverse('financial_assistance_form')}">${_("Apply for Financial Assistance")}</a> + </div> +</div> diff --git a/lms/urls.py b/lms/urls.py index 04153fa85d60d974022abb97e0e94ab3fc777d53..f0e820e79bbc74e5b3e239e06fc86f7c41f74410 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -774,3 +774,22 @@ urlpatterns += ( urlpatterns += ( url(r'^api/', include('edx_proctoring.urls')), ) + +if settings.FEATURES.get('ENABLE_FINANCIAL_ASSISTANCE_FORM'): + urlpatterns += ( + url( + r'^financial-assistance/$', + 'courseware.views.financial_assistance', + name='financial_assistance' + ), + url( + r'^financial-assistance/apply/$', + 'courseware.views.financial_assistance_form', + name='financial_assistance_form' + ), + url( + r'^financial-assistance/submit/$', + 'courseware.views.financial_assistance_request', + name='submit_financial_assistance_request' + ) + )