Commit 4334eb9b by chrisndodge

Merge pull request #10885 from edx/release

Release
parents ae1f4155 bce1c66b
......@@ -39,7 +39,9 @@ class CourseModeForm(forms.ModelForm):
[(CourseMode.DEFAULT_MODE_SLUG, CourseMode.DEFAULT_MODE_SLUG)] +
[(mode_slug, mode_slug) for mode_slug in CourseMode.VERIFIED_MODES] +
[(CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.NO_ID_PROFESSIONAL_MODE)] +
[(mode_slug, mode_slug) for mode_slug in CourseMode.CREDIT_MODES]
[(mode_slug, mode_slug) for mode_slug in CourseMode.CREDIT_MODES] +
# need to keep legacy modes around for awhile
[(CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG, CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG)]
)
mode_slug = forms.ChoiceField(choices=COURSE_MODE_SLUG_CHOICES, label=_("Mode"))
......
......@@ -114,6 +114,13 @@ class CourseMode(models.Model):
# Modes that are allowed to upsell
UPSELL_TO_VERIFIED_MODES = [HONOR, AUDIT]
# Courses purchased through the shoppingcart
# should be "honor". Since we've changed the DEFAULT_MODE_SLUG from
# "honor" to "audit", we still need to have the shoppingcart
# use "honor"
DEFAULT_SHOPPINGCART_MODE_SLUG = HONOR
DEFAULT_SHOPPINGCART_MODE = Mode(HONOR, _('Honor'), 0, '', 'usd', None, None, None)
class Meta(object):
unique_together = ('course_id', 'mode_slug', 'currency')
......
......@@ -295,6 +295,7 @@ def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disa
CertificateStatuses.downloadable: 'ready',
CertificateStatuses.notpassing: 'notpassing',
CertificateStatuses.restricted: 'restricted',
CertificateStatuses.auditing: 'auditing',
}
default_status = 'processing'
......@@ -309,7 +310,7 @@ def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disa
if cert_status is None:
return default_info
is_hidden_status = cert_status['status'] in ('unavailable', 'processing', 'generating', 'notpassing')
is_hidden_status = cert_status['status'] in ('unavailable', 'processing', 'generating', 'notpassing', 'auditing')
if course_overview.certificates_display_behavior == 'early_no_info' and is_hidden_status:
return {}
......@@ -325,7 +326,7 @@ def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disa
'can_unenroll': status not in DISABLE_UNENROLL_CERT_STATES,
}
if (status in ('generating', 'ready', 'notpassing', 'restricted') and
if (status in ('generating', 'ready', 'notpassing', 'restricted', 'auditing') and
course_overview.end_of_course_survey_url is not None):
status_dict.update({
'show_survey_button': True,
......@@ -369,7 +370,7 @@ def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disa
cert_status['download_url']
)
if status in ('generating', 'ready', 'notpassing', 'restricted'):
if status in ('generating', 'ready', 'notpassing', 'restricted', 'auditing'):
if 'grade' not in cert_status:
# Note: as of 11/20/2012, we know there are students in this state-- cs169.1x,
# who need to be regraded (we weren't tracking 'notpassing' at first).
......
......@@ -4,6 +4,7 @@ import sys
from functools import wraps
from django.conf import settings
from django.core.cache import caches
from django.core.validators import ValidationError, validate_email
from django.views.decorators.csrf import requires_csrf_token
from django.views.defaults import server_error
......@@ -115,6 +116,10 @@ def calculate(request):
class _ZendeskApi(object):
CACHE_PREFIX = 'ZENDESK_API_CACHE'
CACHE_TIMEOUT = 60 * 60
def __init__(self):
"""
Instantiate the Zendesk API.
......@@ -150,8 +155,39 @@ class _ZendeskApi(object):
"""
self._zendesk_instance.update_ticket(ticket_id=ticket_id, data=update)
def get_group(self, name):
"""
Find the Zendesk group named `name`. Groups are cached for
CACHE_TIMEOUT seconds.
If a matching group exists, it is returned as a dictionary
with the format specifed by the zendesk package.
def _record_feedback_in_zendesk(realname, email, subject, details, tags, additional_info):
Otherwise, returns None.
"""
cache = caches['default']
cache_key = '{prefix}_group_{name}'.format(prefix=self.CACHE_PREFIX, name=name)
cached = cache.get(cache_key)
if cached:
return cached
groups = self._zendesk_instance.list_groups()['groups']
for group in groups:
if group['name'] == name:
cache.set(cache_key, group, self.CACHE_TIMEOUT)
return group
return None
def _record_feedback_in_zendesk(
realname,
email,
subject,
details,
tags,
additional_info,
group_name=None,
require_update=False
):
"""
Create a new user-requested Zendesk ticket.
......@@ -159,6 +195,12 @@ def _record_feedback_in_zendesk(realname, email, subject, details, tags, additio
additional information from the browser and server, such as HTTP headers
and user state. Returns a boolean value indicating whether ticket creation
was successful, regardless of whether the private comment update succeeded.
If `group_name` is provided, attaches the ticket to the matching Zendesk group.
If `require_update` is provided, returns False when the update does not
succeed. This allows using the private comment to add necessary information
which the user will not see in followup emails from support.
"""
zendesk_api = _ZendeskApi()
......@@ -184,8 +226,18 @@ def _record_feedback_in_zendesk(realname, email, subject, details, tags, additio
"tags": zendesk_tags
}
}
group = None
if group_name is not None:
group = zendesk_api.get_group(group_name)
if group is not None:
new_ticket['ticket']['group_id'] = group['id']
try:
ticket_id = zendesk_api.create_ticket(new_ticket)
if group is None:
# Support uses Zendesk groups to track tickets. In case we
# haven't been able to correctly group this ticket, log its ID
# so it can be found later.
log.warning('Unable to find group named %s for Zendesk ticket with ID %s.', group_name, ticket_id)
except zendesk.ZendeskError:
log.exception("Error creating Zendesk ticket")
return False
......@@ -196,10 +248,12 @@ def _record_feedback_in_zendesk(realname, email, subject, details, tags, additio
try:
zendesk_api.update_ticket(ticket_id, ticket_update)
except zendesk.ZendeskError:
log.exception("Error updating Zendesk ticket")
# The update is not strictly necessary, so do not indicate failure to the user
pass
log.exception("Error updating Zendesk ticket with ID %s.", ticket_id)
# The update is not strictly necessary, so do not indicate
# failure to the user unless it has been requested with
# `require_update`.
if require_update:
return False
return True
......
......@@ -143,15 +143,15 @@ from django.core.urlresolvers import reverse
<div class="register-choice register-choice-audit">
<div class="wrapper-copy">
<span class="deco-ribbon"></span>
<h4 class="title">${_("Earn an Honor Certificate")}</h4>
<h4 class="title">${_("Audit This Course")}</h4>
<div class="copy">
<p>${_("Take this course for free and have complete access to all the course material, activities, tests, and forums. Please note that learners who earn a passing grade will earn a certificate in this course.")}</p>
<p>${_("Audit this course for free and have complete access to all the course material, activities, tests, and forums.")}</p>
</div>
</div>
<ul class="list-actions">
<li class="action action-select">
<input type="submit" name="honor_mode" value="${_('Pursue an Honor Certificate')}" />
<input type="submit" name="honor_mode" value="${_('Audit This Course')}" />
</li>
</ul>
</div>
......@@ -163,9 +163,10 @@ from django.core.urlresolvers import reverse
<div class="register-choice register-choice-audit">
<div class="wrapper-copy">
<span class="deco-ribbon"></span>
<h4 class="title">${_("Audit This Course")}</h4>
<h4 class="title">${_("Audit This Course (No Certificate)")}</h4>
<div class="copy">
<p>${_("Audit this course for free and have complete access to all the course material, activities, tests, and forums. Please note that this track does not offer a certificate for learners who earn a passing grade.")}</p>
## Translators: b_start notes the beginning of a section of text bolded for emphasis, and b_end marks the end of the bolded text.
<p>${_("Audit this course for free and have complete access to all the course material, activities, tests, and forums. {b_start}Please note that this track does not offer a certificate for learners who earn a passing grade.{b_end}".format(**b_tag_kwargs))}</p>
</div>
</div>
......
......@@ -86,6 +86,7 @@ class CertificateStatuses(object):
regenerating = 'regenerating'
restricted = 'restricted'
unavailable = 'unavailable'
auditing = 'auditing'
class CertificateSocialNetworks(object):
......@@ -306,10 +307,20 @@ def certificate_status_for_student(student, course_id):
}
if generated_certificate.grade:
cert_status['grade'] = generated_certificate.grade
if generated_certificate.mode == 'audit':
course_mode_slugs = [mode.slug for mode in CourseMode.modes_for_course(course_id)]
# Short term fix to make sure old audit users with certs still see their certs
# only do this if there if no honor mode
if 'honor' not in course_mode_slugs:
cert_status['status'] = CertificateStatuses.auditing
return cert_status
if generated_certificate.status == CertificateStatuses.downloadable:
cert_status['download_url'] = generated_certificate.download_url
return cert_status
except GeneratedCertificate.DoesNotExist:
pass
return {'status': CertificateStatuses.unavailable, 'mode': GeneratedCertificate.MODES.honor, 'uuid': None}
......
......@@ -522,59 +522,61 @@ class ViewsTestCase(ModuleStoreTestCase):
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,
'course': course,
'name': legal_name,
'email': self.user.email,
'country': country,
'income': income,
'reason_for_applying': reason_for_applying,
'goals': goals,
'effort': effort,
'marketing_permission': False,
'mktg-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)
mocked_kwargs = mock_record_feedback.call_args[1]
group_name = mocked_kwargs['group_name']
require_update = mocked_kwargs['require_update']
private_comment = '\n'.join(additional_info.values())
for info in (country, income, reason_for_applying, goals, effort, username, legal_name, course):
self.assertIn(info, private_comment)
self.assertEqual(additional_info['Allowed for marketing purposes'], 'No')
self.assertEqual(
ticket_subject,
'Financial assistance request for user {username} in course {course}'.format(
'Financial assistance request for learner {username} in course {course}'.format(
username=username,
course=course
course=self.course.display_name
)
)
self.assertDictContainsSubset(
{
'issue_type': 'Financial Assistance',
'course_id': course
},
tags
)
self.assertDictContainsSubset({'course_id': course}, tags)
self.assertIn('Client IP', additional_info)
self.assertEqual(group_name, 'Financial Assistance')
self.assertTrue(require_update)
@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': '',
'course': unicode(self.course.id),
'name': '',
'email': '',
'country': '',
'income': '',
'reason_for_applying': '',
'goals': '',
'effort': '',
'marketing_permission': False,
'mktg-permission': False,
})
self.assertEqual(response.status_code, 500)
@ddt.data(
({}, 400),
({'username': 'wwhite'}, 403)
({'username': 'wwhite'}, 403),
({'username': 'dummy', 'course': 'bad course ID'}, 400)
)
@ddt.unpack
def test_submit_financial_assistance_errors(self, data, status):
......
......@@ -7,6 +7,7 @@ import json
import textwrap
import urllib
from collections import OrderedDict
from datetime import datetime
from django.utils.translation import ugettext as _
......@@ -1404,6 +1405,7 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True):
'disable_accordion': True,
'allow_iframing': True,
'disable_header': True,
'disable_footer': True,
'disable_window_wrap': True,
'disable_preview_menu': True,
'staff_access': bool(has_access(request.user, 'staff', course)),
......@@ -1415,20 +1417,22 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True):
# 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'
'{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.'
' Note that you must complete a separate application for each course you take.\n We will use this'
' information to evaluate your application for financial assistance and to further develop our'
' financial assistance program.'
).format(
percent_sign="%",
platform_name=settings.PLATFORM_NAME
).split('\n')
FA_INCOME_LABEL = _('Annual Income')
FA_INCOME_LABEL = _('Annual Household Income')
FA_REASON_FOR_APPLYING_LABEL = _(
'Tell us about your current financial situation, including any unusual circumstances.'
'Tell us about your current financial situation.'
)
FA_GOALS_LABEL = _(
'Tell us about your learning or professional goals. How will a Verified Certificate in'
......@@ -1436,7 +1440,7 @@ FA_GOALS_LABEL = _(
)
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?'
' the course work and receive a certificate?'
)
FA_SHORT_ANSWER_INSTRUCTIONS = _('Use between 250 and 500 words or so in your response.')
......@@ -1461,65 +1465,54 @@ def financial_assistance_request(request):
if request.user.username != username:
return HttpResponseForbidden()
course_id = data['course_id']
legal_name = data['legal_name']
course_id = data['course']
course = modulestore().get_course(CourseKey.from_string(course_id))
legal_name = data['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']
marketing_permission = data['mktg-permission']
ip_address = get_ip(request)
except ValueError:
# Thrown if JSON parsing fails
return HttpResponseBadRequest('Could not parse request JSON.')
except InvalidKeyError:
# Thrown if course key parsing fails
return HttpResponseBadRequest('Could not parse request course key.')
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(
'Financial assistance request for learner {username} in course {course_name}'.format(
username=username,
course_id=course_id
course_name=course.display_name
),
ticket_body,
{'issue_type': 'Financial Assistance', 'course_id': course_id},
{'Client IP': ip_address}
'Financial Assistance Request',
{'course_id': course_id},
# Send the application as additional info on the ticket so
# that it is not shown when support replies. This uses
# OrderedDict so that information is presented in the right
# order.
OrderedDict((
('Username', username),
('Full Name', legal_name),
('Course ID', course_id),
('Annual Household Income', income),
('Country', country),
('Allowed for marketing purposes', 'Yes' if marketing_permission else 'No'),
(FA_REASON_FOR_APPLYING_LABEL, '\n' + reason_for_applying + '\n\n'),
(FA_GOALS_LABEL, '\n' + goals + '\n\n'),
(FA_EFFORT_LABEL, '\n' + effort + '\n\n'),
('Client IP', ip_address),
)),
group_name='Financial Assistance',
require_update=True
)
if not zendesk_submitted:
......@@ -1630,7 +1623,8 @@ def financial_assistance_form(request):
'type': 'checkbox',
'required': False,
'instructions': _(
'Annual income and personal information such as email address will not be shared.'
'Annual income and personal information such as email address will not be shared. '
'Financial information will not be used for marketing purposes.'
),
'restrictions': {}
}
......
......@@ -111,7 +111,16 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal
if previous_state.user:
# if the student is currently unenrolled, don't enroll them in their
# previous mode
course_mode = CourseMode.DEFAULT_MODE_SLUG
# for now, White Labels use 'shoppingcart' which is based on the
# "honor" course_mode. Given the change to use "audit" as the default
# course_mode in Open edX, we need to be backwards compatible with
# how White Labels approach enrollment modes.
if CourseMode.is_white_label(course_id):
course_mode = CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG
else:
course_mode = CourseMode.DEFAULT_MODE_SLUG
if previous_state.enrollment:
course_mode = previous_state.mode
......
......@@ -362,6 +362,11 @@ class TestInstructorDetailedEnrollmentReport(TestReportMixin, InstructorTaskCour
def setUp(self):
super(TestInstructorDetailedEnrollmentReport, self).setUp()
self.course = CourseFactory.create()
CourseModeFactory.create(
course_id=self.course.id,
min_price=50,
mode_slug=CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG
)
# create testing invoice 1
self.instructor = InstructorFactory(course_key=self.course.id)
......@@ -476,7 +481,7 @@ class TestInstructorDetailedEnrollmentReport(TestReportMixin, InstructorTaskCour
created_by=self.instructor,
invoice=self.sale_invoice_1,
invoice_item=self.invoice_item,
mode_slug=CourseMode.DEFAULT_MODE_SLUG
mode_slug=CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG
)
course_registration_code.save()
......@@ -517,7 +522,7 @@ class TestInstructorDetailedEnrollmentReport(TestReportMixin, InstructorTaskCour
created_by=self.instructor,
invoice=self.sale_invoice_1,
invoice_item=self.invoice_item,
mode_slug=CourseMode.DEFAULT_MODE_SLUG
mode_slug=CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG
)
course_registration_code.save()
......@@ -845,7 +850,11 @@ class TestExecutiveSummaryReport(TestReportMixin, InstructorTaskCourseTestCase):
def setUp(self):
super(TestExecutiveSummaryReport, self).setUp()
self.course = CourseFactory.create()
CourseModeFactory.create(course_id=self.course.id, min_price=50)
CourseModeFactory.create(
course_id=self.course.id,
min_price=50,
mode_slug=CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG
)
self.instructor = InstructorFactory(course_key=self.course.id)
self.student1 = UserFactory()
......
......@@ -229,6 +229,11 @@ class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTest
@patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True})
def test_web_certificate(self):
CourseMode.objects.create(
course_id=self.course.id,
mode_display_name="Honor",
mode_slug=CourseMode.HONOR,
)
self.login_and_enroll()
self.course.cert_html_view_enabled = True
......
# pylint: disable=arguments-differ
""" Models for the shopping cart and assorted purchase types """
from collections import namedtuple
......@@ -1473,7 +1474,7 @@ class PaidCourseRegistration(OrderItem):
app_label = "shoppingcart"
course_id = CourseKeyField(max_length=128, db_index=True)
mode = models.SlugField(default=CourseMode.DEFAULT_MODE_SLUG)
mode = models.SlugField(default=CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG)
course_enrollment = models.ForeignKey(CourseEnrollment, null=True)
@classmethod
......@@ -1526,7 +1527,8 @@ class PaidCourseRegistration(OrderItem):
@classmethod
@transaction.atomic
def add_to_order(cls, order, course_id, mode_slug=CourseMode.DEFAULT_MODE_SLUG, cost=None, currency=None):
def add_to_order(cls, order, course_id, mode_slug=CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG,
cost=None, currency=None): # pylint: disable=arguments-differ
"""
A standardized way to create these objects, with sensible defaults filled in.
Will update the cost if called on an order that already carries the course.
......@@ -1561,7 +1563,7 @@ class PaidCourseRegistration(OrderItem):
course_mode = CourseMode.mode_for_course(course_id, mode_slug)
if not course_mode:
# user could have specified a mode that's not set, in that case return the DEFAULT_MODE
course_mode = CourseMode.DEFAULT_MODE
course_mode = CourseMode.DEFAULT_SHOPPINGCART_MODE
if not cost:
cost = course_mode.min_price
if not currency:
......@@ -1660,7 +1662,7 @@ class CourseRegCodeItem(OrderItem):
app_label = "shoppingcart"
course_id = CourseKeyField(max_length=128, db_index=True)
mode = models.SlugField(default=CourseMode.DEFAULT_MODE_SLUG)
mode = models.SlugField(default=CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG)
@classmethod
def get_bulk_purchased_seat_count(cls, course_key, status='purchased'):
......@@ -1706,7 +1708,8 @@ class CourseRegCodeItem(OrderItem):
@classmethod
@transaction.atomic
def add_to_order(cls, order, course_id, qty, mode_slug=CourseMode.DEFAULT_MODE_SLUG, cost=None, currency=None): # pylint: disable=arguments-differ
def add_to_order(cls, order, course_id, qty, mode_slug=CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG,
cost=None, currency=None): # pylint: disable=arguments-differ
"""
A standardized way to create these objects, with sensible defaults filled in.
Will update the cost if called on an order that already carries the course.
......@@ -1736,8 +1739,8 @@ class CourseRegCodeItem(OrderItem):
### handle default arguments for mode_slug, cost, currency
course_mode = CourseMode.mode_for_course(course_id, mode_slug)
if not course_mode:
# user could have specified a mode that's not set, in that case return the DEFAULT_MODE
course_mode = CourseMode.DEFAULT_MODE
# user could have specified a mode that's not set, in that case return the DEFAULT_SHOPPINGCART_MODE
course_mode = CourseMode.DEFAULT_SHOPPINGCART_MODE
if not cost:
cost = course_mode.min_price
if not currency:
......
......@@ -732,7 +732,7 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
self.assertEqual(reg1.unit_cost, 0)
self.assertEqual(reg1.line_cost, 0)
self.assertEqual(reg1.mode, CourseMode.DEFAULT_MODE_SLUG)
self.assertEqual(reg1.mode, CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG)
self.assertEqual(reg1.user, self.user)
self.assertEqual(reg1.status, "cart")
self.assertEqual(self.cart.total_cost, 0)
......@@ -742,7 +742,7 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase):
self.assertEqual(course_reg_code_item.unit_cost, 0)
self.assertEqual(course_reg_code_item.line_cost, 0)
self.assertEqual(course_reg_code_item.mode, CourseMode.DEFAULT_MODE_SLUG)
self.assertEqual(course_reg_code_item.mode, CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG)
self.assertEqual(course_reg_code_item.user, self.user)
self.assertEqual(course_reg_code_item.status, "cart")
self.assertEqual(self.cart.total_cost, 0)
......
......@@ -247,13 +247,7 @@ class ShoppingCartViewsTests(SharedModuleStoreTestCase, XssTestMixin):
test to check that that the same coupon code applied on multiple
items in the cart.
"""
for course_key, cost in ((self.course_key, 40), (self.testing_course.id, 20)):
CourseMode(
course_id=course_key,
mode_slug=CourseMode.DEFAULT_MODE_SLUG,
mode_display_name=CourseMode.DEFAULT_MODE_SLUG,
min_price=cost
).save()
self.login_user()
# add first course to user cart
resp = self.client.post(
......
;(function (define) {
'use strict';
define([
'js/financial-assistance/views/financial_assistance_form_view'
],
function (FinancialAssistanceFormView) {
return function (options) {
var formView = new FinancialAssistanceFormView({
el: '.financial-assistance-wrapper',
context: options
});
return formView;
};
});
}).call(this, define || RequireJS.define);
/**
* Model for Financial Assistance.
*/
(function (define) {
'use strict';
define(['backbone'], function (Backbone) {
var FinancialAssistance = Backbone.Model.extend({
initialize: function(options) {
this.url = options.url;
}
});
return FinancialAssistance;
});
}).call(this, define || RequireJS.define);
<h1><%- gettext('Financial Assistance Application') %></h1>
<div class="intro">
<% _.each(header_text, function(copy) { %>
<p class="copy"><%- copy %></p>
<% }); %>
</div>
<form class="financial-assistance-form">
<div class="status submission-error hidden" aria-live="polite">
<h4 class="message-title"><%- gettext('Application not submitted') %></h4>
<ul class="message-copy"></ul>
</div>
<div class="user-info">
<h2><%- gettext('About You') %></h2>
<p><%- interpolate_text(
gettext('The following information is already a part of your {platform} profile. We\'ve included it here for your application.'),
{platform: platform_name}
) %></p>
<div class="info-column">
<div class="title"><%- gettext('Username') %></div>
<div class="data"><%- username %></div>
</div>
<div class="info-column">
<div class="title"><%- gettext('Email address') %></div>
<div class="data"><%- email %></div>
</div>
<div class="info-column">
<div class="title"><%- gettext('Legal name') %></div>
<div class="data"><%- name %></div>
</div>
<div class="info-column">
<div class="title"><%- gettext('Country of residence') %></div>
<div class="data"><%- country %></div>
</div>
</div>
<%= fields %>
<div class="cta-wrapper clearfix">
<a href="<%- student_faq_url %>" class="nav-link"><%- interpolate_text(
gettext('Back to {platform} FAQs'),
{platform: platform_name}
) %></a>
<button type="submit" class="action action-primary action-update js-submit-form submit-form"><%- gettext("Submit Application") %></button>
</div>
</form>
<h1><%- gettext('Financial Assistance Application') %></h1>
<p class="js-success-message success-message" tabindex="-1"><%- interpolate_text(
gettext('Thank you for submitting your financial assistance application for {course_name}! You can expect a response in 2-4 business days.'), {course_name: course}
) %>
</p>
<div class="cta-wrapper clearfix">
<a href="<%- dashboard_url %>" class="btn btn-blue btn-dashboard"><%- gettext('Go to Dashboard') %></a>
</div>
;(function (define) {
'use strict';
define(['backbone',
'jquery',
'underscore',
'gettext',
'js/financial-assistance/models/financial_assistance_model',
'text!js/financial-assistance/templates/financial_assessment_form.underscore',
'text!js/financial-assistance/templates/financial_assessment_submitted.underscore',
'js/student_account/views/FormView',
'text!templates/student_account/form_field.underscore'
],
function(Backbone, $, _, gettext, FinancialAssistanceModel, formViewTpl, successTpl, FormView, formFieldTpl) {
return FormView.extend({
el: '.financial-assistance-wrapper',
events: {
'click .js-submit-form': 'submitForm'
},
tpl: formViewTpl,
fieldTpl: formFieldTpl,
formType: 'financial-assistance',
requiredStr: '',
submitButton: '.js-submit-form',
initialize: function(data) {
var context = data.context,
fields = context.fields;
// Add default option to array
if ( fields[0].options.length > 1 ) {
fields[0].options.unshift({
name: '- ' + gettext('Choose one') + ' -',
value: '',
default: true
});
}
// Set non-form data needed to render the View
this.context = {
dashboard_url: context.dashboard_url,
header_text: context.header_text,
platform_name: context.platform_name,
student_faq_url: context.student_faq_url
};
// Make the value accessible to this View
this.user_details = context.user_details;
// Initialize the model and set user details
this.model = new FinancialAssistanceModel({
url: context.submit_url
});
this.model.set( context.user_details );
this.listenTo( this.model, 'error', this.saveError );
this.model.on('sync', this.renderSuccess, this);
// Build the form
this.buildForm( fields );
},
render: function(html) {
var data = _.extend( this.model.toJSON(), this.context, {
fields: html || '',
});
this.$el.html(_.template(this.tpl, data));
this.postRender();
return this;
},
renderSuccess: function() {
this.$el.html(_.template(successTpl, {
course: this.model.get('course'),
dashboard_url: this.context.dashboard_url
}));
$('.js-success-message').focus();
},
saveError: function(error) {
/*jslint maxlen: 500 */
var txt = [
'An error has occurred. Wait a few minutes and then try to submit the application again.',
'If you continue to have issues please contact support.'
],
msg = gettext(txt.join(' '));
if (error.status === 0) {
msg = gettext('An error has occurred. Check your Internet connection and try again.');
}
this.errors = ['<li>' + msg + '</li>'];
this.setErrors();
this.element.hide( this.$resetSuccess );
this.toggleDisableButton(false);
},
setExtraData: function(data) {
return _.extend(data, this.user_details);
}
});
}
);
}).call(this, define || RequireJS.define);
define([
'backbone',
'jquery',
'js/financial-assistance/views/financial_assistance_form_view'
], function (Backbone, $, FinancialAssistanceFormView) {
'use strict';
describe('Financial Assistance View', function () {
var view = null,
context = {
fields: [{
defaultValue: '',
form: 'financial-assistance',
instructions: 'select a course',
label: 'Course',
name: 'course',
options: [
{'name': 'Verified with Audit', 'value': 'course-v1:HCFA+VA101+2015'},
{'name': 'Something Else', 'value': 'course-v1:SomethingX+SE101+215'},
{'name': 'Test Course', 'value': 'course-v1:TestX+T101+2015'}
],
placeholder: '',
required: true,
requiredStr: '',
type: 'select'
}],
user_details: {
country: 'UK',
email: 'xsy@edx.org',
name: 'xsy',
username: 'xsy4ever'
},
header_text: ['Line one.', 'Line two.'],
student_faq_url: '/faqs',
dashboard_url: '/dashboard',
platform_name: 'edx',
submit_url: '/api/financial/v1/assistance'
};
beforeEach(function() {
setFixtures('<div class="financial-assistance-wrapper"></div>');
view = new FinancialAssistanceFormView({
el: '.financial-assistance-wrapper',
context: context
});
});
afterEach(function() {
view.undelegateEvents();
view.remove();
});
it('should exist', function() {
expect(view).toBeDefined();
});
});
}
);
......@@ -213,6 +213,13 @@
this.focusFirstError();
},
/* Allows extended views to add non-form attributes
* to the data before saving it to model
*/
setExtraData: function( data ) {
return data;
},
submitForm: function( event ) {
var data = this.getFormData();
......@@ -223,6 +230,7 @@
this.toggleDisableButton(true);
if ( !_.compact(this.errors).length ) {
data = this.setExtraData( data );
this.model.set( data );
this.model.save();
this.toggleErrorMsg( false );
......
......@@ -21,6 +21,7 @@
'js/discovery/discovery_factory',
'js/edxnotes/views/notes_visibility_factory',
'js/edxnotes/views/page_factory',
'js/financial-assistance/financial_assistance_form_factory',
'js/groups/views/cohorts_dashboard_factory',
'js/search/course/course_search_factory',
'js/search/dashboard/dashboard_search_factory',
......
%fa-copy {
@extend %t-copy-base;
padding: ($baseline/2) 0;
margin: 0;
color: $m-gray-d2;
};
.financial-assistance-wrapper {
margin: auto;
padding: $baseline 0;
padding: $baseline ($baseline/2);
max-width: 1180px;
.financial-assistance {
h1 {
@extend %t-title4;
@include text-align(left);
margin: 0;
padding: ($baseline/2) 0;
border-bottom: 4px solid $gray-l5;
color: $m-gray-d3;
}
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;
}
h2 {
@extend %t-title6;
@extend %t-strong;
margin-top: ($baseline/2);
text-transform: none;
}
p {
@extend %fa-copy;
font-size: 0.875em;
}
p {
@extend %t-copy-base;
padding: ($baseline/2) 0;
margin: 0;
color: $m-gray-d2;
}
.financial-assistance {
padding-bottom: ($baseline/2);
border-bottom: 4px solid $gray-l5;
.apply-form-list {
padding: 0;
......@@ -73,4 +79,165 @@
border-radius: 2px;
}
}
// Application form View
.intro {
border-bottom: 4px solid $gray-l5;
p {
margin: 10px 0;
}
}
.success-message {
p {
margin: 10px 0;
}
}
.btn-dashboard {
@include float(right);
color: $white;
&:hover,
&:active,
&:focus {
color: $white;
}
}
.user-info {
@include clearfix();
border-bottom: 2px solid $gray-l5;
padding: 20px 0;
margin-bottom: 20px;
.info-column {
@include float(left);
width: 100%;
margin: 10px 0;
}
.title {
@extend %fa-copy;
padding: 0;
}
.data {
@extend %fa-copy;
padding: 0;
color: $black;
font-size: 1.125em;
}
}
.financial-assistance-form {
@extend .login-register;
.action-primary {
@include float(left);
width: auto;
margin-top: 0;
}
.nav-link {
margin: 15px 0;
display: block;
}
form {
border: none;
}
.form-field {
select,
input {
width: 320px;
}
input {
border: {
top: none;
right: none;
bottom: 3px solid $gray-l1;
left: none;
};
box-shadow: none;
}
textarea {
height: 125px;
}
.checkbox {
height: auto;
position: absolute;
top: 5px;
& + label {
@include margin-left(30px);
display: inline-block;
}
}
}
}
.cta-wrapper {
border-top: 4px solid $gray-l5;
padding: 20px 0;
}
@include media($bp-medium) {
.user-info {
.info-column {
width: 50%;
}
}
.financial-assistance-form {
.action-primary {
@include float(right);
}
.nav-link {
display: inline-block;
}
}
}
@include media($bp-large) {
.user-info {
.info-column {
width: 25%;
}
}
.financial-assistance-form {
.action-primary {
@include float(right);
}
.nav-link {
display: inline-block;
}
}
}
@include media($bp-huge) {
.user-info {
.info-column {
width: 25%;
}
}
.financial-assistance-form {
.action-primary {
@include float(right);
}
.nav-link {
display: inline-block;
}
}
}
}
......@@ -107,6 +107,3 @@ ${fragment.foot_html()}
</nav>
<%include file="../modal/accessible_confirm.html" />
## No footer in chromeless
<%block name="footer"></%block>
......@@ -27,10 +27,9 @@ else:
status_css_class = 'course-status-processing'
%>
<div class="message message-status ${status_css_class} is-shown">
% if cert_status['status'] == 'processing':
<p class="message-copy">${_("Final course details are being wrapped up at this time. Your final standing will be available shortly.")}</p>
% elif cert_status['status'] in ('generating', 'ready', 'notpassing', 'restricted'):
% elif cert_status['status'] in ('generating', 'ready', 'notpassing', 'restricted', 'auditing'):
<p class="message-copy">${_("Your final grade:")}
<span class="grade-value">${"{0:.0f}%".format(float(cert_status['grade'])*100)}</span>.
% if cert_status['status'] == 'notpassing':
......
......@@ -19,9 +19,9 @@ from edxmako.shortcuts import marketing_link
<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>${_("If you are interested in working toward a Verified Certificate, but cannot afford to pay the fee, please apply now. Please note financial assistance 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>${_("If your application is approved, we'll give you instructions for verifying your identity on edx.org so 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>
......
......@@ -79,7 +79,6 @@ from branding import api as branding_api
% else:
<%static:js group='main_vendor'/>
<%static:js group='application'/>
<%static:js group='module-js'/>
% endif
<script>
......@@ -92,6 +91,10 @@ from branding import api as branding_api
</script>
<script type="text/javascript" src="${static.url("lms/js/require-config.js")}"></script>
% if not disable_courseware_js:
<%static:js group='module-js'/>
% endif
<%block name="headextra"/>
<%static:optional_include_mako file="head-extra.html" with_microsite="True" />
......
......@@ -21,6 +21,7 @@
<option value="<%= el.value%>"<% if ( el.default ) { %> data-isdefault="true"<% } %>><%= el.name %></option>
<% }); %>
</select>
<% if ( instructions ) { %> <span class="tip tip-input" id="<%= form %>-<%= name %>-desc"><%= instructions %></span><% } %>
<% } else if ( type === 'textarea' ) { %>
<textarea id="<%= form %>-<%= name %>"
type="<%= type %>"
......@@ -35,6 +36,7 @@
<% });
} %>
<% if ( required ) { %> aria-required="true" required<% } %> ></textarea>
<% if ( instructions ) { %> <span class="tip tip-input" id="<%= form %>-<%= name %>-desc"><%= instructions %></span><% } %>
<% } else { %>
<input id="<%= form %>-<%= name %>"
type="<%= type %>"
......@@ -52,16 +54,15 @@
<% if ( placeholder ) { %> placeholder="<%= placeholder %>"<% } %>
value="<%- defaultValue %>"
/>
<% if ( type === 'checkbox' ) { %>
<label for="<%= form %>-<%= name %>">
<%= label %>
<% if ( required && requiredStr ) { %> <%= requiredStr %><% } %>
</label>
<% } %>
<% if ( instructions ) { %> <span class="tip tip-input" id="<%= form %>-<%= name %>-desc"><%= instructions %></span><% } %>
<% } %>
<% if ( type === 'checkbox' ) { %>
<label for="<%= form %>-<%= name %>">
<%= label %>
<% if ( required && requiredStr ) { %> <%= requiredStr %><% } %>
</label>
<% } %>
<% if( form === 'login' && name === 'password' ) { %>
<a href="#" class="forgot-password field-link"><%- gettext("Forgot password?") %></a>
<% } %>
......
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