Commit bc121bfb by Peter Fogg

Merge pull request #11026 from edx/patch/2015-12-21

Patch 2015-12-21
parents 98f73c06 484e6ef7
......@@ -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,
......
......@@ -268,7 +268,8 @@ class CertificateGenerationHistory(TimeStampedModel):
"""
Return "regenerated" if record corresponds to Certificate Regeneration task, otherwise returns 'generated'
"""
return "regenerated" if self.is_regeneration else "generated"
# Translators: This is a past-tense verb that is used for task action messages.
return _("regenerated") if self.is_regeneration else _("generated")
def get_certificate_generation_candidates(self):
"""
......@@ -285,7 +286,8 @@ class CertificateGenerationHistory(TimeStampedModel):
task_input_json = json.loads(task_input)
except ValueError:
# if task input is empty, it means certificates were generated for all learners
return "All learners"
# Translators: This string represents task was executed for all learners.
return _("All learners")
# get statuses_to_regenerate from task_input convert statuses to human readable strings and return
statuses = task_input_json.get('statuses_to_regenerate', None)
......@@ -294,9 +296,10 @@ class CertificateGenerationHistory(TimeStampedModel):
[CertificateStatuses.readable_statuses.get(status, "") for status in statuses]
)
# If statuses_to_regenerate is not present in task_input then, certificate generation task was run to
# generate certificates for white listed students
return "for exceptions"
# If students is present in task_input then, certificate generation task was run to
# generate certificates for white listed students otherwise it is for all students.
# Translators: This string represents task was executed for students having exceptions.
return _("For exceptions") if 'students' in task_input_json else _("All learners")
class Meta(object):
app_label = "certificates"
......
......@@ -10,14 +10,13 @@ import json
import logging
import re
import time
import requests
from django.conf import settings
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt
from django.views.decorators.http import require_POST, require_http_methods
from django.views.decorators.cache import cache_control
from django.core.exceptions import ValidationError, PermissionDenied
from django.core.mail.message import EmailMessage
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned
from django.core.exceptions import ObjectDoesNotExist
from django.db import IntegrityError, transaction
from django.core.urlresolvers import reverse
from django.core.validators import validate_email
......@@ -25,11 +24,9 @@ from django.utils.translation import ugettext as _
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound
from django.utils.html import strip_tags
from django.shortcuts import redirect
from util.db import outer_atomic
import string
import random
import unicodecsv
import urllib
import decimal
from student import auth
from student.roles import GlobalStaff, CourseSalesAdminRole, CourseFinanceAdminRole
......@@ -52,7 +49,7 @@ from django_comment_common.models import (
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
)
from edxmako.shortcuts import render_to_response, render_to_string
from edxmako.shortcuts import render_to_string
from courseware.models import StudentModule
from shoppingcart.models import (
Coupon,
......@@ -92,7 +89,7 @@ from instructor.views import INVOICE_KEY
from submissions import api as sub_api # installed from the edx-submissions repository
from certificates import api as certs_api
from certificates.models import CertificateWhitelist, GeneratedCertificate
from certificates.models import CertificateWhitelist, GeneratedCertificate, CertificateStatuses
from bulk_email.models import CourseEmail
from student.models import get_user_by_username_or_email
......@@ -108,7 +105,6 @@ from .tools import (
set_due_date_extension,
strip_if_string,
bulk_email_is_enabled_for_course,
add_block_ids,
)
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
......@@ -2708,7 +2704,7 @@ def start_certificate_regeneration(request, course_id):
)
# Check if the selected statuses are allowed
allowed_statuses = GeneratedCertificate.get_unique_statuses(course_key=course_key, flat=True)
allowed_statuses = [CertificateStatuses.downloadable, CertificateStatuses.error, CertificateStatuses.notpassing]
if not set(certificates_statuses).issubset(allowed_statuses):
return JsonResponse(
{'message': _('Please select certificate statuses from the list only.')},
......@@ -2789,11 +2785,18 @@ def add_certificate_exception(course_key, student, certificate_exception):
}
)
generated_certificate = GeneratedCertificate.objects.filter(
user=student,
course_id=course_key,
status=CertificateStatuses.downloadable,
).first()
exception = dict({
'id': certificate_white_list.id,
'user_email': student.email,
'user_name': student.username,
'user_id': student.id,
'certificate_generated': generated_certificate and generated_certificate.created_date.strftime("%B %d, %Y"),
'created': certificate_white_list.created.strftime("%A, %B %d, %Y"),
})
......
......@@ -324,7 +324,8 @@ def _section_certificates(course):
'active_certificate': certs_api.get_active_web_certificate(course),
'certificate_statuses_with_count': certificate_statuses_with_count,
'status': CertificateStatuses,
'certificate_generation_history': CertificateGenerationHistory.objects.filter(course_id=course.id),
'certificate_generation_history':
CertificateGenerationHistory.objects.filter(course_id=course.id).order_by("-created"),
'urls': {
'generate_example_certificates': reverse(
'generate_example_certificates',
......
......@@ -1421,7 +1421,11 @@ def generate_students_certificates(
task_progress.update_task_state(extra_meta=current_step)
statuses_to_regenerate = task_input.get('statuses_to_regenerate', [])
students_require_certs = students_require_certificate(course_id, enrolled_students, statuses_to_regenerate)
if students is not None and not statuses_to_regenerate:
# We want to skip 'filtering students' only when students are given and statuses to regenerate are not
students_require_certs = enrolled_students
else:
students_require_certs = students_require_certificate(course_id, enrolled_students, statuses_to_regenerate)
if statuses_to_regenerate:
# Mark existing generated certificates as 'unavailable' before regenerating
......
......@@ -1650,7 +1650,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
result
)
def test_certificate_regeneration_for_students(self):
def test_certificate_regeneration_for_statuses_to_regenerate(self):
"""
Verify that certificates are regenerated for all eligible students enrolled in a course whose generated
certificate statuses lies in the list 'statuses_to_regenerate' given in task_input.
......@@ -1937,3 +1937,78 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
if cert.status == CertificateStatuses.unavailable and cert.grade == default_grade]
self.assertEquals(len(unavailable_certificates), 2)
def test_certificate_regeneration_for_students(self):
"""
Verify that certificates are regenerated for all students passed in task_input.
"""
# create 10 students
students = [self.create_student(username='student_{}'.format(i), email='student_{}@example.com'.format(i))
for i in xrange(1, 11)]
# mark 2 students to have certificates generated already
for student in students[:2]:
GeneratedCertificateFactory.create(
user=student,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='honor'
)
# mark 3 students to have certificates generated with status 'error'
for student in students[2:5]:
GeneratedCertificateFactory.create(
user=student,
course_id=self.course.id,
status=CertificateStatuses.error,
mode='honor'
)
# mark 6th students to have certificates generated with status 'deleted'
for student in students[5:6]:
GeneratedCertificateFactory.create(
user=student,
course_id=self.course.id,
status=CertificateStatuses.deleted,
mode='honor'
)
# mark 7th students to have certificates generated with status 'norpassing'
for student in students[6:7]:
GeneratedCertificateFactory.create(
user=student,
course_id=self.course.id,
status=CertificateStatuses.notpassing,
mode='honor'
)
# white-list 7 students
for student in students[:7]:
CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
current_task = Mock()
current_task.update_state = Mock()
# Certificates should be regenerated for students having generated certificates with status
# 'downloadable' or 'error' which are total of 5 students in this test case
task_input = {'students': [student.id for student in students]}
with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task:
mock_current_task.return_value = current_task
with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_queue:
mock_queue.return_value = (0, "Successfully queued")
result = generate_students_certificates(
None, None, self.course.id, task_input, 'certificates generated'
)
self.assertDictContainsSubset(
{
'action_name': 'certificates generated',
'total': 10,
'attempted': 10,
'succeeded': 7,
'failed': 3,
'skipped': 0,
},
result
)
"""
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."),
},
]
......
......@@ -32,7 +32,12 @@
render: function(){
var template = this.loadTemplate('certificate-white-list');
this.$el.html(template({certificates: this.collection.models}));
if (this.collection.isEmpty()) {
this.$("#generate-exception-certificates").addClass("is-disabled");
}
else {
this.$("#generate-exception-certificates").removeClass("is-disabled");
}
},
loadTemplate: function(name) {
......
......@@ -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