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
This commit is contained in:
@@ -1489,7 +1489,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,
|
||||
|
||||
15
lms/djangoapps/support/serializers.py
Normal file
15
lms/djangoapps/support/serializers.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
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')
|
||||
@@ -0,0 +1,18 @@
|
||||
;(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);
|
||||
@@ -0,0 +1,13 @@
|
||||
;(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);
|
||||
@@ -0,0 +1,24 @@
|
||||
(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);
|
||||
@@ -0,0 +1,22 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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: {}
|
||||
}
|
||||
};
|
||||
});
|
||||
105
lms/djangoapps/support/static/support/js/views/enrollment.js
Normal file
105
lms/djangoapps/support/static/support/js/views/enrollment.js
Normal file
@@ -0,0 +1,105 @@
|
||||
;(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);
|
||||
@@ -0,0 +1,76 @@
|
||||
;(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);
|
||||
@@ -0,0 +1,26 @@
|
||||
<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>
|
||||
@@ -0,0 +1,62 @@
|
||||
<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>
|
||||
<% } %>
|
||||
@@ -1,13 +1,26 @@
|
||||
# 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 *
|
||||
|
||||
145
lms/djangoapps/support/views/enrollments.py
Normal file
145
lms/djangoapps/support/views/enrollments.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
30
lms/templates/support/enrollment.html
Normal file
30
lms/templates/support/enrollment.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<%!
|
||||
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>
|
||||
Reference in New Issue
Block a user