Commit 66719b1b by Peter Fogg

Add financial assistance page.

Page skeleton and endpoint to submit FA requests to Zendesk.

ECOM-2824
ECOM-3038
parent 47b0d492
...@@ -8,7 +8,7 @@ import ddt ...@@ -8,7 +8,7 @@ import ddt
import json import json
import itertools import itertools
import unittest import unittest
from datetime import datetime from datetime import datetime, timedelta
from HTMLParser import HTMLParser from HTMLParser import HTMLParser
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
...@@ -32,6 +32,7 @@ from certificates import api as certs_api ...@@ -32,6 +32,7 @@ from certificates import api as certs_api
from certificates.models import CertificateStatuses, CertificateGenerationConfiguration from certificates.models import CertificateStatuses, CertificateGenerationConfiguration
from certificates.tests.factories import GeneratedCertificateFactory from certificates.tests.factories import GeneratedCertificateFactory
from course_modes.models import CourseMode from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory
from courseware.model_data import set_score from courseware.model_data import set_score
from courseware.testutils import RenderXBlockTestMixin from courseware.testutils import RenderXBlockTestMixin
from courseware.tests.factories import StudentModuleFactory from courseware.tests.factories import StudentModuleFactory
...@@ -190,7 +191,8 @@ class ViewsTestCase(ModuleStoreTestCase): ...@@ -190,7 +191,8 @@ class ViewsTestCase(ModuleStoreTestCase):
self.component = ItemFactory.create(category='problem', parent_location=self.vertical.location) self.component = ItemFactory.create(category='problem', parent_location=self.vertical.location)
self.course_key = self.course.id 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.date = datetime(2013, 1, 22, tzinfo=UTC)
self.enrollment = CourseEnrollment.enroll(self.user, self.course_key) self.enrollment = CourseEnrollment.enroll(self.user, self.course_key)
self.enrollment.created = self.date self.enrollment.created = self.date
...@@ -270,7 +272,7 @@ class ViewsTestCase(ModuleStoreTestCase): ...@@ -270,7 +272,7 @@ class ViewsTestCase(ModuleStoreTestCase):
self.section.location.name, self.section.location.name,
'f' '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) response = self.client.get(request_url)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
...@@ -283,7 +285,7 @@ class ViewsTestCase(ModuleStoreTestCase): ...@@ -283,7 +285,7 @@ class ViewsTestCase(ModuleStoreTestCase):
self.section.location.name, self.section.location.name,
'1' '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): for idx, val in enumerate(url_parts):
url_parts_copy = url_parts[:] url_parts_copy = url_parts[:]
url_parts_copy[idx] = val + u'χ' url_parts_copy[idx] = val + u'χ'
...@@ -458,6 +460,136 @@ class ViewsTestCase(ModuleStoreTestCase): ...@@ -458,6 +460,136 @@ class ViewsTestCase(ModuleStoreTestCase):
# Verify that the email opt-in checkbox does not appear # Verify that the email opt-in checkbox does not appear
self.assertNotContains(response, checkbox_html, html=True) 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') @attr('shard_1')
# setting TIME_ZONE_DISPLAYED_FOR_DEADLINES explicitly # setting TIME_ZONE_DISPLAYED_FOR_DEADLINES explicitly
......
...@@ -3,8 +3,9 @@ Courseware views functions ...@@ -3,8 +3,9 @@ Courseware views functions
""" """
import logging import logging
import urllib
import json import json
import textwrap
import urllib
from datetime import datetime from datetime import datetime
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
...@@ -16,15 +17,18 @@ from django.core.urlresolvers import reverse ...@@ -16,15 +17,18 @@ from django.core.urlresolvers import reverse
from django.contrib.auth.models import User, AnonymousUser from django.contrib.auth.models import User, AnonymousUser
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.utils.timezone import UTC from django.utils.timezone import UTC
from django.views.decorators.http import require_GET, require_POST, require_http_methods 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 django.shortcuts import redirect
from certificates import api as certs_api from certificates import api as certs_api
from edxmako.shortcuts import render_to_response, render_to_string, marketing_link 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.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control from django.views.decorators.cache import cache_control
from ipware.ip import get_ip
from markupsafe import escape from markupsafe import escape
from rest_framework import status
from courseware import grades from courseware import grades
from courseware.access import has_access, _adjust_start_date_for_beta_testers from courseware.access import has_access, _adjust_start_date_for_beta_testers
...@@ -72,6 +76,7 @@ from shoppingcart.models import CourseRegistrationCode ...@@ -72,6 +76,7 @@ from shoppingcart.models import CourseRegistrationCode
from shoppingcart.utils import is_shopping_cart_enabled from shoppingcart.utils import is_shopping_cart_enabled
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from util.milestones_helpers import get_prerequisite_courses_display from util.milestones_helpers import get_prerequisite_courses_display
from util.views import _record_feedback_in_zendesk
from microsite_configuration import microsite from microsite_configuration import microsite
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
...@@ -1404,3 +1409,229 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True): ...@@ -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'), 'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'),
} }
return render_to_response('courseware/courseware-chromeless.html', context) 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': {}
}
],
})
...@@ -2732,3 +2732,10 @@ PROCTORING_SETTINGS = {} ...@@ -2732,3 +2732,10 @@ PROCTORING_SETTINGS = {}
# The reason we introcuced this number is because we do not want the CCX # The reason we introcuced this number is because we do not want the CCX
# to compete with the MOOC. # to compete with the MOOC.
CCX_MAX_STUDENTS_ALLOWED = 200 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
...@@ -549,3 +549,6 @@ AUTHENTICATION_BACKENDS += ('lti_provider.users.LtiBackend',) ...@@ -549,3 +549,6 @@ AUTHENTICATION_BACKENDS += ('lti_provider.users.LtiBackend',)
# ORGANIZATIONS # ORGANIZATIONS
FEATURES['ORGANIZATIONS_APP'] = True FEATURES['ORGANIZATIONS_APP'] = True
# Financial assistance page
FEATURES['ENABLE_FINANCIAL_ASSISTANCE_FORM'] = True
...@@ -53,6 +53,7 @@ ...@@ -53,6 +53,7 @@
@import 'views/shoppingcart'; @import 'views/shoppingcart';
@import 'views/homepage'; @import 'views/homepage';
@import 'views/support'; @import 'views/support';
@import "views/financial-assistance";
@import 'course/auto-cert'; @import 'course/auto-cert';
// app - discussion // app - discussion
......
.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;
}
}
}
<%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>
<%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>
...@@ -774,3 +774,22 @@ urlpatterns += ( ...@@ -774,3 +774,22 @@ urlpatterns += (
urlpatterns += ( urlpatterns += (
url(r'^api/', include('edx_proctoring.urls')), 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'
)
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment