diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index d3976288a4..b2fd02a7ff 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -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,
diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py
index e85b72d942..f1b23f30e1 100644
--- a/lms/djangoapps/certificates/models.py
+++ b/lms/djangoapps/certificates/models.py
@@ -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"
diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py
index bab6c47f89..1c16b3ce10 100644
--- a/lms/djangoapps/instructor/views/api.py
+++ b/lms/djangoapps/instructor/views/api.py
@@ -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"),
})
diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py
index dba499e1c2..3fefde4464 100644
--- a/lms/djangoapps/instructor/views/instructor_dashboard.py
+++ b/lms/djangoapps/instructor/views/instructor_dashboard.py
@@ -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',
diff --git a/lms/djangoapps/instructor_task/tasks_helper.py b/lms/djangoapps/instructor_task/tasks_helper.py
index 8808db2a5a..f0ef86eb43 100644
--- a/lms/djangoapps/instructor_task/tasks_helper.py
+++ b/lms/djangoapps/instructor_task/tasks_helper.py
@@ -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
diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
index 6a8b087f39..5d4b5f0627 100644
--- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
+++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
@@ -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
+ )
diff --git a/lms/djangoapps/support/serializers.py b/lms/djangoapps/support/serializers.py
new file mode 100644
index 0000000000..2e117e71a1
--- /dev/null
+++ b/lms/djangoapps/support/serializers.py
@@ -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')
diff --git a/lms/djangoapps/support/static/support/js/collections/enrollment.js b/lms/djangoapps/support/static/support/js/collections/enrollment.js
new file mode 100644
index 0000000000..5a8491a02b
--- /dev/null
+++ b/lms/djangoapps/support/static/support/js/collections/enrollment.js
@@ -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);
diff --git a/lms/djangoapps/support/static/support/js/enrollment_factory.js b/lms/djangoapps/support/static/support/js/enrollment_factory.js
new file mode 100644
index 0000000000..9e285b6b3b
--- /dev/null
+++ b/lms/djangoapps/support/static/support/js/enrollment_factory.js
@@ -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);
diff --git a/lms/djangoapps/support/static/support/js/models/enrollment.js b/lms/djangoapps/support/static/support/js/models/enrollment.js
new file mode 100644
index 0000000000..1fb8ead6a8
--- /dev/null
+++ b/lms/djangoapps/support/static/support/js/models/enrollment.js
@@ -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);
diff --git a/lms/djangoapps/support/static/support/js/spec/collections/enrollment_spec.js b/lms/djangoapps/support/static/support/js/spec/collections/enrollment_spec.js
new file mode 100644
index 0000000000..92435ce6a6
--- /dev/null
+++ b/lms/djangoapps/support/static/support/js/spec/collections/enrollment_spec.js
@@ -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');
+ });
+ });
+});
diff --git a/lms/djangoapps/support/static/support/js/spec/models/enrollment_spec.js b/lms/djangoapps/support/static/support/js/spec/models/enrollment_spec.js
new file mode 100644
index 0000000000..1e18136918
--- /dev/null
+++ b/lms/djangoapps/support/static/support/js/spec/models/enrollment_spec.js
@@ -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({});
+ });
+ });
+});
diff --git a/lms/djangoapps/support/static/support/js/spec/certificates_spec.js b/lms/djangoapps/support/static/support/js/spec/views/certificates_spec.js
similarity index 100%
rename from lms/djangoapps/support/static/support/js/spec/certificates_spec.js
rename to lms/djangoapps/support/static/support/js/spec/views/certificates_spec.js
diff --git a/lms/djangoapps/support/static/support/js/spec/views/enrollment_modal_spec.js b/lms/djangoapps/support/static/support/js/spec/views/enrollment_modal_spec.js
new file mode 100644
index 0000000000..66c0b39221
--- /dev/null
+++ b/lms/djangoapps/support/static/support/js/spec/views/enrollment_modal_spec.js
@@ -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('
');
+ 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('