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
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
......
......@@ -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': {}
}
],
})
......@@ -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
......@@ -549,3 +549,6 @@ AUTHENTICATION_BACKENDS += ('lti_provider.users.LtiBackend',)
# ORGANIZATIONS
FEATURES['ORGANIZATIONS_APP'] = True
# Financial assistance page
FEATURES['ENABLE_FINANCIAL_ASSISTANCE_FORM'] = True
......@@ -53,6 +53,7 @@
@import 'views/shoppingcart';
@import 'views/homepage';
@import 'views/support';
@import "views/financial-assistance";
@import 'course/auto-cert';
// 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 += (
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'
)
)
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