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('