Commit a680919e by Peter Fogg

Support tool for changing enrollments.

Allows support staff or global staff to view a list of a learner's
enrollments, and change enrollment modes. We generate a
ManualEnrollmentAudit record for these enrollment changes in order to
track updates. Additionally, enrollment changes are handled through
the enrollment API, which handles bookkeeping such as granting refunds
where appropriate.

ECOM-2825
parent 1e96c81d
......@@ -1486,7 +1486,7 @@ class ManualEnrollmentAudit(models.Model):
"""
saves the student manual enrollment information
"""
cls.objects.create(
return cls.objects.create(
enrolled_by=user,
enrolled_email=email,
state_transition=state_transition,
......
"""
Serializers for use in the support app.
"""
from rest_framework import serializers
from student.models import ManualEnrollmentAudit
class ManualEnrollmentSerializer(serializers.ModelSerializer):
"""Serializes a manual enrollment audit object."""
enrolled_by = serializers.SlugRelatedField(slug_field='email', read_only=True, default='')
class Meta(object):
model = ManualEnrollmentAudit
fields = ('enrolled_by', 'time_stamp', 'reason')
;(function (define) {
'use strict';
define(['backbone', 'support/js/models/enrollment'],
function(Backbone, EnrollmentModel) {
return Backbone.Collection.extend({
model: EnrollmentModel,
initialize: function(models, options) {
this.user = options.user || '';
this.baseUrl = options.baseUrl;
},
url: function() {
return this.baseUrl + this.user;
}
});
});
}).call(this, define || RequireJS.define);
;(function (define) {
'use strict';
define([
'underscore',
'support/js/views/enrollment'
], function (_, EnrollmentView) {
return function (options) {
options = _.extend({el: '.enrollment-content'}, options);
return new EnrollmentView(options).render();
};
});
}).call(this, define || RequireJS.define);
(function (define) {
'use strict';
define(['backbone', 'underscore'], function (Backbone, _) {
return Backbone.Model.extend({
updateEnrollment: function (new_mode, reason) {
return $.ajax({
url: this.url(),
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
course_id: this.get('course_id'),
new_mode: new_mode,
old_mode: this.get('mode'),
reason: reason
}),
success: _.bind(function (response) {
this.set('manual_enrollment', response);
this.set('mode', new_mode);
}, this)
});
}
});
});
}).call(this, define || RequireJS.define);
define([
'common/js/spec_helpers/ajax_helpers',
'support/js/spec_helpers/enrollment_helpers',
'support/js/collections/enrollment',
], function (AjaxHelpers, EnrollmentHelpers, EnrollmentCollection) {
'use strict';
describe('EnrollmentCollection', function () {
var enrollmentCollection;
beforeEach(function () {
enrollmentCollection = new EnrollmentCollection([EnrollmentHelpers.mockEnrollmentData], {
user: 'test-user',
baseUrl: '/support/enrollment/'
});
});
it('sets its URL based on the user', function () {
expect(enrollmentCollection.url()).toEqual('/support/enrollment/test-user');
});
});
});
define([
'common/js/spec_helpers/ajax_helpers',
'support/js/spec_helpers/enrollment_helpers',
'support/js/models/enrollment'
], function (AjaxHelpers, EnrollmentHelpers, EnrollmentModel) {
'use strict';
describe('EnrollmentModel', function () {
var enrollment;
beforeEach(function () {
enrollment = new EnrollmentModel(EnrollmentHelpers.mockEnrollmentData);
enrollment.url = function () {
return '/support/enrollment/test-user';
};
});
it('can save an enrollment to the server and updates itself on success', function () {
var requests = AjaxHelpers.requests(this),
manual_enrollment = {
'enrolled_by': 'staff@edx.org',
'reason': 'Financial Assistance'
};
enrollment.updateEnrollment('verified', 'Financial Assistance');
AjaxHelpers.expectJsonRequest(requests, 'POST', '/support/enrollment/test-user', {
course_id: EnrollmentHelpers.TEST_COURSE,
new_mode: 'verified',
old_mode: 'audit',
reason: 'Financial Assistance'
});
AjaxHelpers.respondWithJson(requests, manual_enrollment);
expect(enrollment.get('mode')).toEqual('verified');
expect(enrollment.get('manual_enrollment')).toEqual(manual_enrollment);
});
it('does not update itself on a server error', function () {
var requests = AjaxHelpers.requests(this);
enrollment.updateEnrollment('verified', 'Financial Assistance');
AjaxHelpers.respondWithError(requests, 500);
expect(enrollment.get('mode')).toEqual('audit');
expect(enrollment.get('manual_enrollment')).toEqual({});
});
});
});
define([
'underscore',
'common/js/spec_helpers/ajax_helpers',
'support/js/spec_helpers/enrollment_helpers',
'support/js/models/enrollment',
'support/js/views/enrollment_modal'
], function (_, AjaxHelpers, EnrollmentHelpers, EnrollmentModel, EnrollmentModal) {
'use strict';
describe('EnrollmentModal', function () {
var modal;
beforeEach(function () {
var enrollment = new EnrollmentModel(EnrollmentHelpers.mockEnrollmentData);
enrollment.url = function () {
return '/support/enrollment/test-user';
};
setFixtures('<div class="enrollment-modal-wrapper is-hidden"></div>');
modal = new EnrollmentModal({
el: $('.enrollment-modal-wrapper'),
enrollment: enrollment,
modes: ['verified', 'audit'],
reasons: _.reduce(
['Financial Assistance', 'Stampeding Buffalo', 'Angry Customer'],
function (acc, x) { acc[x] = x; return acc; },
{}
)
}).render();
});
it('can render itself', function () {
expect($('.enrollment-modal h1').text()).toContain(
'Change enrollment for ' + EnrollmentHelpers.TEST_COURSE
);
expect($('.enrollment-change-field p').first().text()).toContain('Current enrollment mode: audit');
_.each(['verified', 'audit'], function (mode) {
expect($('.enrollment-new-mode').html()).toContain('<option value="' + mode + '">');
});
_.each(['', 'Financial Assistance', 'Stampeding Buffalo', 'Angry Customer'], function (reason) {
expect($('.enrollment-reason').html()).toContain('<option value="' + reason + '">');
});
});
it('is hidden by default', function () {
expect($('.enrollment-modal-wrapper')).toHaveClass('is-hidden');
});
it('can show and hide itself', function () {
modal.show();
expect($('.enrollment-modal-wrapper')).not.toHaveClass('is-hidden');
modal.hide();
expect($('.enrollment-modal-wrapper')).toHaveClass('is-hidden');
});
it('shows errors on submit if a reason is not given', function () {
expect($('.enrollment-change-errors').css('display')).toEqual('none');
$('.enrollment-change-submit').click();
expect($('.enrollment-change-errors').css('display')).not.toEqual('none');
expect($('.enrollment-change-errors').text()).toContain('Please specify a reason.');
});
it('can does not error if a free-form reason is given', function () {
AjaxHelpers.requests(this);
$('.enrollment-reason-other').val('For Fun');
$('.enrollment-change-submit').click();
expect($('.enrollment-change-errors').css('display')).toEqual('none');
});
it('can submit an enrollment change request and hides itself on success', function () {
var requests = AjaxHelpers.requests(this);
$('.enrollment-new-mode').val('verified');
$('.enrollment-reason').val('Financial Assistance');
$('.enrollment-change-submit').click();
AjaxHelpers.expectJsonRequest(requests, 'POST', '/support/enrollment/test-user', {
course_id: EnrollmentHelpers.TEST_COURSE,
new_mode: 'verified',
old_mode: 'audit',
reason: 'Financial Assistance'
});
AjaxHelpers.respondWithJson(requests, {
'enrolled_by': 'staff@edx.org',
'reason': 'Financial Assistance'
});
expect($('.enrollment-change-errors').css('display')).toEqual('none');
});
it('shows a message on a server error', function () {
var requests = AjaxHelpers.requests(this);
$('.enrollment-new-mode').val('verified');
$('.enrollment-reason').val('Financial Assistance');
$('.enrollment-change-submit').click();
AjaxHelpers.respondWithError(requests, 500);
expect($('.enrollment-change-errors').css('display')).not.toEqual('none');
expect($('.enrollment-change-errors').text()).toContain('Something went wrong');
});
it('hides itself on cancel', function () {
var requests = AjaxHelpers.requests(this);
modal.show();
$('.enrollment-change-cancel').click();
AjaxHelpers.expectNoRequests(requests);
expect($('.enrollment-change-errors').css('display')).toEqual('none');
});
});
});
define([
'underscore',
'common/js/spec_helpers/ajax_helpers',
'support/js/spec_helpers/enrollment_helpers',
'support/js/views/enrollment'
], function (_, AjaxHelpers, EnrollmentHelpers, EnrollmentView) {
'use strict';
var enrollmentView,
createEnrollmentView = function (options) {
if (_.isUndefined(options)) {
options = {};
}
return new EnrollmentView(_.extend({}, {
el: '.enrollment-content',
user: 'test-user',
enrollmentsUrl: '/support/enrollment/',
enrollmentSupportUrl: '/support/enrollment/',
}, options));
};
beforeEach(function () {
setFixtures('<div class="enrollment-content"></div>');
});
describe('EnrollmentView', function () {
it('can render itself without an initial user', function () {
enrollmentView = createEnrollmentView({user: ''}).render();
expect($('.enrollment-search input').val()).toBe('');
expect($('.enrollment-results').length).toBe(0);
});
it('renders itself when an initial user is provided', function () {
var requests = AjaxHelpers.requests(this);
enrollmentView = createEnrollmentView().render();
AjaxHelpers.expectRequest(requests, 'GET', '/support/enrollment/test-user', null);
AjaxHelpers.respondWithJson(requests, [EnrollmentHelpers.mockEnrollmentData]);
expect($('.enrollment-search input').val()).toBe('test-user');
expect($('.enrollment-results').length).toBe(1);
expect($('.enrollment-results td button').first().data()).toEqual({
course_id: EnrollmentHelpers.TEST_COURSE,
modes: 'audit,verified'
});
});
it('re-renders itself when its collection changes', function () {
var requests = AjaxHelpers.requests(this);
enrollmentView = createEnrollmentView().render();
spyOn(enrollmentView, 'render').andCallThrough();
AjaxHelpers.respondWithJson(requests, [EnrollmentHelpers.mockEnrollmentData]);
expect(enrollmentView.render).toHaveBeenCalled();
});
it('shows a modal dialog to change enrollments', function () {
var requests = AjaxHelpers.requests(this);
enrollmentView = createEnrollmentView().render();
AjaxHelpers.respondWithJson(requests, [EnrollmentHelpers.mockEnrollmentData]);
enrollmentView.$('.change-enrollment-btn').first().click();
expect($('.enrollment-modal').length).toBe(1);
});
});
});
define([], function () {
'use strict';
var testCourse = "course-v1:TestX+T101+2015";
return {
TEST_COURSE: testCourse,
mockEnrollmentData: {
created: "2015-12-07T18:17:46.210940Z",
mode: "audit",
is_active: true,
user: "test-user",
course_end: "2017-01-01T00:00:00Z",
course_start: "2015-01-01T00:00:00Z",
course_modes: [
{
slug: "audit",
name: "Audit",
min_price: 0,
suggested_prices: "",
currency: "usd",
expiration_datetime: null,
description: null,
sku: "6ED7EDC"
},
{
slug: "verified",
name: "Verified Certificate",
min_price: 5,
suggested_prices: "",
currency: "usd",
expiration_datetime: null,
description: null,
sku: "25A5354"
}
],
enrollment_start: null,
course_id: testCourse,
invite_only: false,
enrollment_end: null,
verified_price: 5,
verified_upgrade_deadline: null,
verification_deadline: null,
manual_enrollment: {}
}
};
});
;(function (define) {
'use strict';
define([
'backbone',
'underscore',
'moment',
'support/js/views/enrollment_modal',
'support/js/collections/enrollment',
'text!support/templates/enrollment.underscore'
], function (Backbone, _, moment, EnrollmentModal, EnrollmentCollection, enrollmentTemplate) {
return Backbone.View.extend({
ENROLLMENT_CHANGE_REASONS: {
'Financial Assistance': gettext('Financial Assistance'),
'Upset Learner': gettext('Upset Learner'),
'Teaching Assistant': gettext('Teaching Assistant')
},
events: {
'submit .enrollment-form': 'search',
'click .change-enrollment-btn': 'changeEnrollment'
},
initialize: function (options) {
var user = options.user;
this.initialUser = user;
this.enrollmentSupportUrl = options.enrollmentSupportUrl;
this.enrollments = new EnrollmentCollection([], {
user: user,
baseUrl: options.enrollmentsUrl
});
this.enrollments.on('change', _.bind(this.render, this));
},
render: function () {
var user = this.enrollments.user;
this.$el.html(_.template(enrollmentTemplate, {
user: user,
enrollments: this.enrollments,
formatDate: function (date) {
if (!date) {
return 'N/A';
}
else {
return moment(date).format('MM/DD/YYYY (H:MM UTC)');
}
}
}));
this.checkInitialSearch();
return this;
},
/*
* Check if the URL has provided an initial search, and
* perform that search if so.
*/
checkInitialSearch: function () {
if (this.initialUser) {
delete this.initialUser;
this.$('.enrollment-form').submit();
}
},
/*
* Return the user's search string.
*/
getSearchString: function () {
return this.$('#enrollment-query-input').val();
},
/*
* Perform the search. Renders the view on success.
*/
search: function (event) {
event.preventDefault();
this.enrollments.user = this.getSearchString();
this.enrollments.fetch({
success: _.bind(function () {
this.render();
}, this)
});
},
/*
* Show a modal view allowing the user to change a
* learner's enrollment.
*/
changeEnrollment: function (event) {
var button = $(event.currentTarget),
course_id = button.data('course_id'),
modes = button.data('modes').split(','),
enrollment = this.enrollments.findWhere({course_id: course_id});
event.preventDefault();
new EnrollmentModal({
el: this.$('.enrollment-modal-wrapper'),
enrollment: enrollment,
modes: modes,
reasons: this.ENROLLMENT_CHANGE_REASONS
}).show();
}
});
});
}).call(this, define || RequireJS.define);
;(function (define) {
'use strict';
define([
'backbone',
'underscore',
'gettext',
'text!support/templates/enrollment-modal.underscore'
], function (Backbone, _, gettext, modalTemplate) {
var EnrollmentModal = Backbone.View.extend({
events: {
'click .enrollment-change-submit': 'submitEnrollmentChange',
'click .enrollment-change-cancel': 'cancel'
},
initialize: function (options) {
this.enrollment = options.enrollment;
this.modes = options.modes;
this.reasons = options.reasons;
this.template = modalTemplate;
},
render: function () {
this.$el.html(_.template(this.template, {
enrollment: this.enrollment,
modes: this.modes,
reasons: this.reasons,
}));
return this;
},
show: function () {
this.$el.removeClass('is-hidden').addClass('is-shown');
this.render();
},
hide: function () {
this.$el.removeClass('is-shown').addClass('is-hidden');
this.render();
},
showErrors: function (errorMessage) {
this.$('.enrollment-change-errors').text(errorMessage).css('display', '');
},
submitEnrollmentChange: function (event) {
var new_mode = this.$('.enrollment-new-mode').val(),
reason = this.$('.enrollment-reason').val() || this.$('.enrollment-reason-other').val();
event.preventDefault();
if (!reason) {
this.showErrors(gettext('Please specify a reason.'));
}
else {
this.enrollment.updateEnrollment(new_mode, reason).then(
// Success callback
_.bind(function () {
this.hide();
}, this),
// Error callback
_.bind(function () {
this.showErrors(gettext(
'Something went wrong changing this enrollment. Please try again.'
));
}, this)
);
}
},
cancel: function (event) {
event.preventDefault();
this.hide();
}
});
return EnrollmentModal;
});
}).call(this, define || RequireJS.define);
<form class="enrollment-modal">
<h1 class="enrollment-change-title">Change enrollment for <%- enrollment.get('course_id') %></h1>
<p class="enrollment-change-errors" style="display: none;"></p>
<div class="enrollment-change-field"><p><%- gettext("Current enrollment mode:") %> <%- enrollment.get('mode') %></p></div>
<div class="enrollment-change-field">
<label for="enrollment-new-mode"><%- gettext("New enrollment mode:") %></label>
<select class="enrollment-new-mode" id="enrollment-new-mode">
<% _.each(modes, function (mode) { %>
<option value="<%- mode %>"><%- mode %></option>
<% }) %>
</select>
</div>
<div class="enrollment-change-field">
<label for="enrollment-reason"><%- gettext("Reason for change:") %></label>
<select class="enrollment-reason" id="enrollment-reason">
<option value=""><%- gettext("Choose One") %></option>
<% _.each(reasons, function (translated_reason, reason) { %>
<option value="<%- reason %>"><%- translated_reason %></option>
<% }) %>
</select>
<label class="sr" for="enrollment-reason-other"><%- gettext("Explain if other.") %></label>
<input class="enrollment-reason-other" id="enrollment-reason-other" type="text" placeholder="<%- gettext('Explain if other.') %>" />
</div>
<button class="enrollment-change-submit"><%- gettext("Submit enrollment change") %></button>
<button class="enrollment-change-cancel"><%- gettext("Cancel") %></button>
</form>
<div class="enrollment-search">
<form class="enrollment-form">
<label class="sr" for="enrollment-query-input"><%- gettext('Search') %></label>
<input
id="enrollment-query-input"
type="text"
name="query"
value="<%- user %>"
placeholder="<%- gettext('Username') %>">
</input>
<input type="submit" value="<%- gettext('Search') %>" class="btn-disable-on-submit"></input>
</form>
</div>
<% if (enrollments.length > 0) { %>
<div class="enrollment-results">
<table id="enrollment-table" class="enrollment-table display compact nowrap">
<thead>
<tr>
<th><%- gettext('Course ID') %></th>
<th><%- gettext('Course Start') %></th>
<th><%- gettext('Course End') %></th>
<th><%- gettext('Upgrade Deadline') %></th>
<th><%- gettext('Verification Deadline') %></th>
<th><%- gettext('Enrollment Date') %></th>
<th><%- gettext('Enrollment Mode') %></th>
<th><%- gettext('Verified mode price') %></th>
<th><%- gettext('Reason') %></th>
<th><%- gettext('Last modified by') %></th>
<th></th>
</tr>
</thead>
<tbody>
<% enrollments.each(function (enrollment) { %>
<tr>
<td><% print(enrollment.get('course_id')) %></td>
<td><% print(formatDate(enrollment.get('course_start'))) %></td>
<td><% print(formatDate(enrollment.get('course_end'))) %></td>
<td><% print(formatDate(enrollment.get('verified_upgrade_deadline'))) %></td>
<td><% print(formatDate(enrollment.get('verification_deadline'))) %></td>
<td><% print(formatDate(enrollment.get('created'))) %></td>
<td><% print(enrollment.get('mode')) %></td>
<td><% print(enrollment.get('verified_price')) %></td>
<td><% print(enrollment.get('manual_enrollment').reason || gettext('N/A')) %></td>
<td><% print(enrollment.get('manual_enrollment').enrolled_by || gettext('N/A')) %></td>
<td>
<button
class="change-enrollment-btn"
data-modes="<%= _.pluck(enrollment.get('course_modes'), 'slug')%>"
data-course_id="<%= enrollment.get('course_id') %>"
>
<%- gettext('Change Enrollment') %>
</button>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<div class="enrollment-modal-wrapper is-hidden"></div>
<% } %>
# coding: UTF-8
"""
Tests for support views.
"""
from datetime import datetime, timedelta
import itertools
import json
import re
import ddt
from django.test import TestCase
from django.core.urlresolvers import reverse
from pytz import UTC
from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory
from lms.djangoapps.verify_student.models import VerificationDeadline
from student.models import CourseEnrollment, ManualEnrollmentAudit, ENROLLED_TO_ENROLLED
from student.roles import GlobalStaff, SupportStaffRole
from student.tests.factories import UserFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
class SupportViewTestCase(TestCase):
......@@ -33,17 +46,21 @@ class SupportViewAccessTests(SupportViewTestCase):
Tests for access control of support views.
"""
@ddt.data(
("support:index", GlobalStaff, True),
("support:index", SupportStaffRole, True),
("support:index", None, False),
("support:certificates", GlobalStaff, True),
("support:certificates", SupportStaffRole, True),
("support:certificates", None, False),
("support:refund", GlobalStaff, True),
("support:refund", SupportStaffRole, True),
("support:refund", None, False),
)
@ddt.data(*(
(url_name, role, has_access)
for (url_name, (role, has_access))
in itertools.product((
'support:index',
'support:certificates',
'support:refund',
'support:enrollment',
'support:enrollment_list'
), (
(GlobalStaff, True),
(SupportStaffRole, True),
(None, False)
))
))
@ddt.unpack
def test_access(self, url_name, role, has_access):
if role is not None:
......@@ -57,7 +74,13 @@ class SupportViewAccessTests(SupportViewTestCase):
else:
self.assertEqual(response.status_code, 403)
@ddt.data("support:index", "support:certificates", "support:refund")
@ddt.data(
"support:index",
"support:certificates",
"support:refund",
"support:enrollment",
"support:enrollment_list"
)
def test_require_login(self, url_name):
url = reverse(url_name)
......@@ -116,3 +139,114 @@ class SupportViewCertificatesTests(SupportViewTestCase):
url = reverse("support:certificates") + "?query=student@example.com"
response = self.client.get(url)
self.assertContains(response, "userQuery: 'student@example.com'")
@ddt.ddt
class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase):
"""Tests for the enrollment support view."""
def setUp(self):
super(SupportViewEnrollmentsTests, self).setUp()
SupportStaffRole().add_users(self.user)
self.course = CourseFactory(display_name=u'teꜱᴛ')
self.student = UserFactory.create(username='student', email='test@example.com', password='test')
for mode in (CourseMode.AUDIT, CourseMode.VERIFIED):
CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) # pylint: disable=no-member
self.verification_deadline = VerificationDeadline(
course_key=self.course.id, # pylint: disable=no-member
deadline=datetime.now(UTC) + timedelta(days=365)
)
self.verification_deadline.save()
CourseEnrollmentFactory.create(mode=CourseMode.AUDIT, user=self.student, course_id=self.course.id) # pylint: disable=no-member
self.url = reverse('support:enrollment_list', kwargs={'username': self.student.username})
def assert_enrollment(self, mode):
"""
Assert that the student's enrollment has the correct mode.
"""
enrollment = CourseEnrollment.get_enrollment(self.student, self.course.id) # pylint: disable=no-member
self.assertEqual(enrollment.mode, mode)
def test_get_enrollments(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertEqual(len(data), 1)
self.assertDictContainsSubset({
'mode': CourseMode.AUDIT,
'manual_enrollment': {},
'user': self.student.username,
'course_id': unicode(self.course.id), # pylint: disable=no-member
'is_active': True,
'verified_upgrade_deadline': None,
}, data[0])
self.assertEqual(
{CourseMode.VERIFIED, CourseMode.AUDIT},
{mode['slug'] for mode in data[0]['course_modes']}
)
def test_get_manual_enrollment_history(self):
ManualEnrollmentAudit.create_manual_enrollment_audit(
self.user,
self.student.email,
ENROLLED_TO_ENROLLED,
'Financial Assistance',
CourseEnrollment.objects.get(course_id=self.course.id, user=self.student) # pylint: disable=no-member
)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertDictContainsSubset({
'enrolled_by': self.user.email,
'reason': 'Financial Assistance',
}, json.loads(response.content)[0]['manual_enrollment'])
def test_change_enrollment(self):
self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email))
response = self.client.post(self.url, data={
'course_id': unicode(self.course.id), # pylint: disable=no-member
'old_mode': CourseMode.AUDIT,
'new_mode': CourseMode.VERIFIED,
'reason': 'Financial Assistance'
})
self.assertEqual(response.status_code, 200)
self.assertIsNotNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email))
self.assert_enrollment(CourseMode.VERIFIED)
@ddt.data(
({}, r"The field '\w+' is required."),
({'course_id': 'bad course key'}, 'Could not parse course key.'),
({
'course_id': 'course-v1:TestX+T101+2015',
'old_mode': CourseMode.AUDIT,
'new_mode': CourseMode.VERIFIED,
'reason': ''
}, 'Could not find enrollment for user'),
({
'course_id': None,
'old_mode': CourseMode.HONOR,
'new_mode': CourseMode.VERIFIED,
'reason': ''
}, r'User \w+ is not enrolled with mode ' + CourseMode.HONOR),
({
'course_id': None,
'old_mode': CourseMode.AUDIT,
'new_mode': CourseMode.CREDIT_MODE,
'reason': ''
}, "Specified course mode '{}' unavailable".format(CourseMode.CREDIT_MODE))
)
@ddt.unpack
def test_change_enrollment_bad_data(self, data, error_message):
# `self` isn't available from within the DDT declaration, so
# assign the course ID here
if 'course_id' in data and data['course_id'] is None:
data['course_id'] = unicode(self.course.id) # pylint: disable=no-member
response = self.client.post(self.url, data)
self.assertEqual(response.status_code, 400)
self.assertIsNotNone(re.match(error_message, response.content))
self.assert_enrollment(CourseMode.AUDIT)
self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email))
......@@ -10,4 +10,6 @@ urlpatterns = patterns(
url(r'^$', views.index, name="index"),
url(r'^certificates/?$', views.CertificatesSupportView.as_view(), name="certificates"),
url(r'^refund/?$', views.RefundSupportView.as_view(), name="refund"),
url(r'^enrollment/?$', views.EnrollmentSupportView.as_view(), name="enrollment"),
url(r'^enrollment/(?P<username>[\w.@+-]+)?$', views.EnrollmentSupportListView.as_view(), name="enrollment_list"),
)
......@@ -4,4 +4,5 @@ Aggregate all views for the support app.
# pylint: disable=wildcard-import
from .index import *
from .certificate import *
from .enrollments import *
from .refund import *
"""
Support tool for changing course enrollments.
"""
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.db import transaction
from django.http import HttpResponseBadRequest
from django.utils.decorators import method_decorator
from django.views.generic import View
from rest_framework.generics import GenericAPIView
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from course_modes.models import CourseMode
from edxmako.shortcuts import render_to_response
from enrollment.api import get_enrollments, update_enrollment
from enrollment.errors import CourseModeNotFoundError
from lms.djangoapps.support.decorators import require_support_permission
from lms.djangoapps.support.serializers import ManualEnrollmentSerializer
from lms.djangoapps.verify_student.models import VerificationDeadline
from student.models import CourseEnrollment, ManualEnrollmentAudit, ENROLLED_TO_ENROLLED
from util.json_request import JsonResponse
class EnrollmentSupportView(View):
"""
View for viewing and changing learner enrollments, used by the
support team.
"""
@method_decorator(require_support_permission)
def get(self, request):
"""Render the enrollment support tool view."""
return render_to_response('support/enrollment.html', {
'username': request.GET.get('user', ''),
'enrollmentsUrl': reverse('support:enrollment_list'),
'enrollmentSupportUrl': reverse('support:enrollment')
})
class EnrollmentSupportListView(GenericAPIView):
"""
Allows viewing and changing learner enrollments by support
staff.
"""
@method_decorator(require_support_permission)
def get(self, request, username):
"""
Returns a list of enrollments for the given user, along with
information about previous manual enrollment changes.
"""
enrollments = get_enrollments(username)
for enrollment in enrollments:
# Folds the course_details field up into the main JSON object.
enrollment.update(**enrollment.pop('course_details'))
course_key = CourseKey.from_string(enrollment['course_id'])
# Add the price of the course's verified mode.
self.include_verified_mode_info(enrollment, course_key)
# Add manual enrollment history, if it exists
enrollment['manual_enrollment'] = self.manual_enrollment_data(enrollment, course_key)
return JsonResponse(enrollments)
@method_decorator(require_support_permission)
def post(self, request, username):
"""Allows support staff to alter a user's enrollment."""
try:
course_id = request.data['course_id']
course_key = CourseKey.from_string(course_id)
old_mode = request.data['old_mode']
new_mode = request.data['new_mode']
reason = request.data['reason']
enrollment = CourseEnrollment.objects.get(course_id=course_key, user__username=username)
if enrollment.mode != old_mode:
return HttpResponseBadRequest(u'User {username} is not enrolled with mode {old_mode}.'.format(
username=username,
old_mode=old_mode
))
except KeyError as err:
return HttpResponseBadRequest(u'The field {} is required.'.format(err.message))
except InvalidKeyError:
return HttpResponseBadRequest(u'Could not parse course key.')
except CourseEnrollment.DoesNotExist:
return HttpResponseBadRequest(
u'Could not find enrollment for user {username} in course {course}.'.format(
username=username,
course=unicode(course_key)
)
)
try:
# Wrapped in a transaction so that we can be sure the
# ManualEnrollmentAudit record is always created correctly.
with transaction.atomic():
update_enrollment(username, course_id, mode=new_mode)
manual_enrollment = ManualEnrollmentAudit.create_manual_enrollment_audit(
request.user,
enrollment.user.email,
ENROLLED_TO_ENROLLED,
reason=reason,
enrollment=enrollment
)
return JsonResponse(ManualEnrollmentSerializer(instance=manual_enrollment).data)
except CourseModeNotFoundError as err:
return HttpResponseBadRequest(err.message)
@staticmethod
def include_verified_mode_info(enrollment_data, course_key):
"""
Add information about the verified mode for the given
`course_key`, if that course has a verified mode.
Args:
enrollment_data (dict): Dictionary representing a single enrollment.
course_key (CourseKey): The course which this enrollment belongs to.
Returns:
None
"""
course_modes = enrollment_data['course_modes']
for mode in course_modes:
if mode['slug'] == CourseMode.VERIFIED:
enrollment_data['verified_price'] = mode['min_price']
enrollment_data['verified_upgrade_deadline'] = mode['expiration_datetime']
enrollment_data['verification_deadline'] = VerificationDeadline.deadline_for_course(course_key)
@staticmethod
def manual_enrollment_data(enrollment_data, course_key):
"""
Returns serialized information about the manual enrollment
belonging to this enrollment, if it exists.
Args:
enrollment_data (dict): Representation of a single course enrollment.
course_key (CourseKey): The course for this enrollment.
Returns:
None: If no manual enrollment change has been made.
dict: Serialization of the latest manual enrollment change.
"""
user = User.objects.get(username=enrollment_data['user'])
enrollment = CourseEnrollment.get_enrollment(user, course_key)
manual_enrollment_audit = ManualEnrollmentAudit.get_manual_enrollment(enrollment)
if manual_enrollment_audit is None:
return {}
return ManualEnrollmentSerializer(instance=manual_enrollment_audit).data
......@@ -22,6 +22,11 @@ SUPPORT_INDEX_URLS = [
"name": _("Manual Refund"),
"description": _("Track refunds issued directly through CyberSource."),
},
{
"url": reverse_lazy("support:enrollment"),
"name": _("Enrollment"),
"description": _("View and update learner enrollments."),
},
]
......
......@@ -713,7 +713,11 @@
'lms/include/js/spec/discovery/views/search_form_spec.js',
'lms/include/js/spec/discovery/discovery_factory_spec.js',
'lms/include/js/spec/ccx/schedule_spec.js',
'lms/include/support/js/spec/certificates_spec.js',
'lms/include/support/js/spec/collections/enrollment_spec.js',
'lms/include/support/js/spec/models/enrollment_spec.js',
'lms/include/support/js/spec/views/enrollment_modal_spec.js',
'lms/include/support/js/spec/views/enrollment_spec.js',
'lms/include/support/js/spec/views/certificates_spec.js',
'lms/include/teams/js/spec/collections/topic_collection_spec.js',
'lms/include/teams/js/spec/teams_tab_factory_spec.js',
'lms/include/teams/js/spec/views/edit_team_spec.js',
......
......@@ -31,7 +31,8 @@
'js/student_profile/views/learner_profile_factory',
'js/views/message_banner',
'teams/js/teams_tab_factory',
'support/js/certificates_factory'
'support/js/certificates_factory',
'support/js/enrollment_factory',
]),
/**
......
......@@ -2,7 +2,7 @@
// These styles are included on admin pages used by the support team.
// ===================================================================
.certificates-search {
.certificates-search, .enrollment-search {
margin: 40px 0;
input[name="query"] {
......@@ -30,3 +30,96 @@
.btn-cert-regenerate {
font-size: 12px;
}
.enrollment-modal-wrapper.is-shown {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
.enrollment-modal {
width: 600px;
position: relative;
margin: 10% auto;
padding: $baseline;
border: 4px solid $gray;
border-radius: 4px;
background-color: $white;
.enrollment-change-title {
@extend %t-title6;
@extend %t-strong;
@include text-align(left);
margin-bottom: 0;
text-transform: none;
}
.enrollment-change-field {
margin: 0;
padding: ($baseline/4) 0;
border-bottom: 1px solid $gray-l1;
p, label, select, input {
@extend %t-copy-sub1;
display: inline;
font-style: normal;
}
}
.enrollment-change-errors {
@extend %t-copy-sub1;
@extend %t-light;
color: $red;
}
.enrollment-info {
padding: 0;
margin: 0;
list-style: none;
}
.enrollment-change-submit, .enrollment-change-cancel {
@extend %t-action4;
margin: ($baseline/4) auto;
text-transform: none;
background-image: none;
border: none;
box-shadow: none;
text-shadow: none;
}
.enrollment-change-cancel {
background-color: $gray-l3;
}
}
}
.enrollment-modal-wrapper.is-hidden {
display: none;
}
.enrollment-results {
@extend %t-copy-sub2;
.enrollment-table {
display: inline-block;
}
th {
@extend %t-title7;
}
.change-enrollment-btn, .change-enrollment-btn:hover {
@extend %t-action4;
margin: ($baseline/4) auto;
padding: ($baseline/4) 1px;
letter-spacing: normal;
text-transform: none;
background-image: none;
border: none;
box-shadow: none;
text-shadow: none;
}
}
<%!
from django.utils.translation import ugettext as _
from openedx.core.lib.js_utils import escape_json_dumps
%>
<%namespace name='static' file='../static_content.html'/>
<%inherit file="../main.html" />
<%block name="js_extra">
<%static:require_module module_name="support/js/enrollment_factory" class_name="EnrollmentFactory">
new EnrollmentFactory({
user: ${escape_json_dumps(username)},
enrollmentsUrl: ${escape_json_dumps(enrollmentsUrl)},
enrollmentSupportUrl: ${escape_json_dumps(enrollmentSupportUrl)},
});
</%static:require_module>
</%block>
<%block name="pagetitle">
${_("Enrollment")}
</%block>
<%block name="content">
<section class="container outside-app">
<h1>${_("Student Support: Enrollment")}</h1>
<div class="enrollment-content"></div>
</section>
</%block>
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