Commit 611d16b2 by Zia Fazal Committed by Matt Drayer

ziafazal/SOL-980: Generate certificates from instructor dashboard

* added generate certificates task and bok choy tests
* added unit tests
* changes based feedback and improved acceptance test
* Change header text
* changes based on feedback on 24/6
* added task_id to api output
* fixed broken test
* Remove "Instructor" from strings, per Docs team
* Fixed flaky entrance exam test
parent 4526b929
...@@ -57,6 +57,15 @@ class InstructorDashboardPage(CoursePage): ...@@ -57,6 +57,15 @@ class InstructorDashboardPage(CoursePage):
student_admin_section.wait_for_page() student_admin_section.wait_for_page()
return student_admin_section return student_admin_section
def select_certificates(self):
"""
Selects the certificates tab and returns the CertificatesSection
"""
self.q(css='a[data-section=certificates]').first.click()
certificates_section = CertificatesPage(self.browser)
certificates_section.wait_for_page()
return certificates_section
@staticmethod @staticmethod
def get_asset_path(file_name): def get_asset_path(file_name):
""" """
...@@ -884,3 +893,41 @@ class StudentAdminPage(PageObject): ...@@ -884,3 +893,41 @@ class StudentAdminPage(PageObject):
""" """
input_box = self.student_email_input.first.results[0] input_box = self.student_email_input.first.results[0]
input_box.send_keys(email_addres) input_box.send_keys(email_addres)
class CertificatesPage(PageObject):
"""
Certificates section of the Instructor dashboard.
"""
url = None
PAGE_SELECTOR = 'section#certificates'
def is_browser_on_page(self):
return self.q(css='a[data-section=certificates].active-section').present
def get_selector(self, css_selector):
"""
Makes query selector by pre-pending certificates section
"""
return self.q(css=' '.join([self.PAGE_SELECTOR, css_selector]))
@property
def generate_certificates_button(self):
"""
Returns the "Generate Certificates" button.
"""
return self.get_selector('#btn-start-generating-certificates')
@property
def certificate_generation_status(self):
"""
Returns certificate generation status message container.
"""
return self.get_selector('div.certificate-generation-status')
@property
def pending_tasks_section(self):
"""
Returns the "Pending Instructor Tasks" section.
"""
return self.get_selector('div.running-tasks-container')
...@@ -49,6 +49,10 @@ class SettingsPage(CoursePage): ...@@ -49,6 +49,10 @@ class SettingsPage(CoursePage):
""" """
Returns the pre-requisite course drop down field options. Returns the pre-requisite course drop down field options.
""" """
self.wait_for_element_visibility(
'#pre-requisite-course',
'Prerequisite course element is available'
)
return self.get_elements('#pre-requisite-course') return self.get_elements('#pre-requisite-course')
@property @property
...@@ -56,6 +60,10 @@ class SettingsPage(CoursePage): ...@@ -56,6 +60,10 @@ class SettingsPage(CoursePage):
""" """
Returns the enable entrance exam checkbox. Returns the enable entrance exam checkbox.
""" """
self.wait_for_element_visibility(
'#entrance-exam-enabled',
'Entrance exam checkbox is available'
)
return self.get_element('#entrance-exam-enabled') return self.get_element('#entrance-exam-enabled')
@property @property
...@@ -64,6 +72,10 @@ class SettingsPage(CoursePage): ...@@ -64,6 +72,10 @@ class SettingsPage(CoursePage):
Returns the alert confirmation element, which contains text Returns the alert confirmation element, which contains text
such as 'Your changes have been saved.' such as 'Your changes have been saved.'
""" """
self.wait_for_element_visibility(
'#alert-confirmation-title',
'Alert confirmation title element is available'
)
return self.get_element('#alert-confirmation-title') return self.get_element('#alert-confirmation-title')
@property @property
......
...@@ -4,6 +4,7 @@ End-to-end tests for the LMS Instructor Dashboard. ...@@ -4,6 +4,7 @@ End-to-end tests for the LMS Instructor Dashboard.
""" """
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from bok_choy.promise import EmptyPromise
from ..helpers import UniqueCourseTest, get_modal_alert, EventsTestMixin from ..helpers import UniqueCourseTest, get_modal_alert, EventsTestMixin
from ...pages.common.logout import LogoutPage from ...pages.common.logout import LogoutPage
...@@ -396,3 +397,46 @@ class DataDownloadsTest(BaseInstructorDashboardTest): ...@@ -396,3 +397,46 @@ class DataDownloadsTest(BaseInstructorDashboardTest):
self.data_download_section.wait_for_available_report() self.data_download_section.wait_for_available_report()
self.verify_report_requested_event(report_name) self.verify_report_requested_event(report_name)
self.verify_report_download(report_name) self.verify_report_download(report_name)
@attr('shard_5')
class CertificatesTest(BaseInstructorDashboardTest):
"""
Tests for Certificates functionality on instructor dashboard.
"""
def setUp(self):
super(CertificatesTest, self).setUp()
self.course_fixture = CourseFixture(**self.course_info).install()
self.log_in_as_instructor()
instructor_dashboard_page = self.visit_instructor_dashboard()
self.certificates_section = instructor_dashboard_page.select_certificates()
def test_generate_certificates_buttons_is_visible(self):
"""
Scenario: On the Certificates tab of the Instructor Dashboard, Generate Certificates button is visible.
Given that I am on the Certificates tab on the Instructor Dashboard
Then I see 'Generate Certificates' button
And when I click on 'Generate Certificates' button
Then I should see a status message and 'Generate Certificates' button should be disabled.
"""
self.assertTrue(self.certificates_section.generate_certificates_button.visible)
self.certificates_section.generate_certificates_button.click()
alert = get_modal_alert(self.certificates_section.browser)
alert.accept()
self.certificates_section.wait_for_ajax()
EmptyPromise(
lambda: self.certificates_section.certificate_generation_status.visible,
'Certificate generation status shown'
).fulfill()
disabled = self.certificates_section.generate_certificates_button.attrs('disabled')
self.assertEqual(disabled[0], 'true')
def test_pending_tasks_section_is_visible(self):
"""
Scenario: On the Certificates tab of the Instructor Dashboard, Pending Instructor Tasks section is visible.
Given that I am on the Certificates tab on the Instructor Dashboard
Then I see 'Pending Instructor Tasks' section
"""
self.assertTrue(self.certificates_section.pending_tasks_section.visible)
[ [
{ {
"pk": 99, "pk": 99,
"model": "certificates.certificategenerationconfiguration",
"fields": {
"change_date": "2015-06-18 11:02:13",
"changed_by": 99,
"enabled": true
}
},
{
"pk": 99,
"model": "auth.user", "model": "auth.user",
"fields": { "fields": {
"date_joined": "2015-06-12 11:02:13", "date_joined": "2015-06-12 11:02:13",
......
...@@ -26,7 +26,8 @@ from certificates.queue import XQueueCertInterface ...@@ -26,7 +26,8 @@ from certificates.queue import XQueueCertInterface
log = logging.getLogger("edx.certificate") log = logging.getLogger("edx.certificate")
def generate_user_certificates(student, course_key, course=None, insecure=False, generation_mode='batch'): def generate_user_certificates(student, course_key, course=None, insecure=False, generation_mode='batch',
forced_grade=None):
""" """
It will add the add-cert request into the xqueue. It will add the add-cert request into the xqueue.
...@@ -45,12 +46,17 @@ def generate_user_certificates(student, course_key, course=None, insecure=False, ...@@ -45,12 +46,17 @@ def generate_user_certificates(student, course_key, course=None, insecure=False,
insecure - (Boolean) insecure - (Boolean)
generation_mode - who has requested certificate generation. Its value should `batch` generation_mode - who has requested certificate generation. Its value should `batch`
in case of django command and `self` if student initiated the request. in case of django command and `self` if student initiated the request.
forced_grade - a string indicating to replace grade parameter. if present grading
will be skipped.
""" """
xqueue = XQueueCertInterface() xqueue = XQueueCertInterface()
if insecure: if insecure:
xqueue.use_https = False xqueue.use_https = False
generate_pdf = not has_html_certificates_enabled(course_key, course) generate_pdf = not has_html_certificates_enabled(course_key, course)
status, cert = xqueue.add_cert(student, course_key, course=course, generate_pdf=generate_pdf) status, cert = xqueue.add_cert(student, course_key,
course=course,
generate_pdf=generate_pdf,
forced_grade=forced_grade)
if status in [CertificateStatuses.generating, CertificateStatuses.downloadable]: if status in [CertificateStatuses.generating, CertificateStatuses.downloadable]:
emit_certificate_event('created', student, course_key, course, { emit_certificate_event('created', student, course_key, course, {
'user_id': student.id, 'user_id': student.id,
......
...@@ -2,13 +2,15 @@ ...@@ -2,13 +2,15 @@
import contextlib import contextlib
import ddt import ddt
import mock import mock
import json
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test.utils import override_settings from django.test.utils import override_settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from config_models.models import cache from config_models.models import cache
from courseware.tests.factories import GlobalStaffFactory, InstructorFactory from courseware.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory
from certificates.models import CertificateGenerationConfiguration from certificates.models import CertificateGenerationConfiguration
from certificates import api as certs_api from certificates import api as certs_api
...@@ -222,3 +224,39 @@ class CertificatesInstructorApiTest(ModuleStoreTestCase): ...@@ -222,3 +224,39 @@ class CertificatesInstructorApiTest(ModuleStoreTestCase):
) )
expected_redirect += '#view-certificates' expected_redirect += '#view-certificates'
self.assertRedirects(response, expected_redirect) self.assertRedirects(response, expected_redirect)
def test_certificate_generation_api_without_global_staff(self):
"""
Test certificates generation api endpoint returns permission denied if
user who made the request is not member of global staff.
"""
user = UserFactory.create()
self.client.login(username=user.username, password='test')
url = reverse(
'start_certificate_generation',
kwargs={'course_id': unicode(self.course.id)}
)
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
self.client.login(username=self.instructor.username, password='test')
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
def test_certificate_generation_api_with_global_staff(self):
"""
Test certificates generation api endpoint returns success status when called with
valid course key
"""
self.client.login(username=self.global_staff.username, password='test')
url = reverse(
'start_certificate_generation',
kwargs={'course_id': unicode(self.course.id)}
)
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
res_json = json.loads(response.content)
self.assertIsNotNone(res_json['message'])
self.assertIsNotNone(res_json['task_id'])
...@@ -2642,3 +2642,22 @@ def mark_student_can_skip_entrance_exam(request, course_id): # pylint: disable= ...@@ -2642,3 +2642,22 @@ def mark_student_can_skip_entrance_exam(request, course_id): # pylint: disable=
'message': message, 'message': message,
} }
return JsonResponse(response_payload) return JsonResponse(response_payload)
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_global_staff
@require_POST
def start_certificate_generation(request, course_id):
"""
Start generating certificates for all students enrolled in given course.
"""
course_key = CourseKey.from_string(course_id)
task = instructor_task.api.generate_certificates_for_all_students(request, course_key)
message = _('Certificate generation task for all students of this course has been started. '
'You can view the status of the generation task in the "Pending Tasks" section.')
response_payload = {
'message': message,
'task_id': task.task_id
}
return JsonResponse(response_payload)
...@@ -132,4 +132,8 @@ urlpatterns = patterns( ...@@ -132,4 +132,8 @@ urlpatterns = patterns(
url(r'^enable_certificate_generation$', url(r'^enable_certificate_generation$',
'instructor.views.api.enable_certificate_generation', 'instructor.views.api.enable_certificate_generation',
name='enable_certificate_generation'), name='enable_certificate_generation'),
url(r'^start_certificate_generation',
'instructor.views.api.start_certificate_generation',
name='start_certificate_generation'),
) )
...@@ -259,7 +259,15 @@ def _section_certificates(course): ...@@ -259,7 +259,15 @@ def _section_certificates(course):
'enable_certificate_generation': reverse( 'enable_certificate_generation': reverse(
'enable_certificate_generation', 'enable_certificate_generation',
kwargs={'course_id': course.id} kwargs={'course_id': course.id}
) ),
'start_certificate_generation': reverse(
'start_certificate_generation',
kwargs={'course_id': course.id}
),
'list_instructor_tasks_url': reverse(
'list_instructor_tasks',
kwargs={'course_id': course.id}
),
} }
} }
......
...@@ -24,7 +24,8 @@ from instructor_task.tasks import ( ...@@ -24,7 +24,8 @@ from instructor_task.tasks import (
cohort_students, cohort_students,
enrollment_report_features_csv, enrollment_report_features_csv,
calculate_may_enroll_csv, calculate_may_enroll_csv,
exec_summary_report_csv exec_summary_report_csv,
generate_certificates,
) )
from instructor_task.api_helper import ( from instructor_task.api_helper import (
...@@ -419,3 +420,17 @@ def submit_cohort_students(request, course_key, file_name): ...@@ -419,3 +420,17 @@ def submit_cohort_students(request, course_key, file_name):
task_key = "" task_key = ""
return submit_task(request, task_type, task_class, course_key, task_input, task_key) return submit_task(request, task_type, task_class, course_key, task_input, task_key)
def generate_certificates_for_all_students(request, course_key): # pylint: disable=invalid-name
"""
Submits a task to generate certificates for all students enrolled in the course.
Raises AlreadyRunningError if certificates are currently being generated.
"""
task_type = 'generate_certificates_all_student'
task_class = generate_certificates
task_input = {}
task_key = ""
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
...@@ -40,7 +40,8 @@ from instructor_task.tasks_helper import ( ...@@ -40,7 +40,8 @@ from instructor_task.tasks_helper import (
cohort_students_and_upload, cohort_students_and_upload,
upload_enrollment_report, upload_enrollment_report,
upload_may_enroll_csv, upload_may_enroll_csv,
upload_exec_summary_report upload_exec_summary_report,
generate_students_certificates,
) )
...@@ -225,6 +226,22 @@ def calculate_may_enroll_csv(entry_id, xmodule_instance_args): ...@@ -225,6 +226,22 @@ def calculate_may_enroll_csv(entry_id, xmodule_instance_args):
return run_main_task(entry_id, task_fn, action_name) return run_main_task(entry_id, task_fn, action_name)
@task(base=BaseInstructorTask, routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY) # pylint: disable=not-callable
def generate_certificates(entry_id, xmodule_instance_args):
"""
Grade students and generate certificates.
"""
# Translators: This is a past-tense verb that is inserted into task progress messages as {action}.
action_name = ugettext_noop('certificates generated')
TASK_LOG.info(
u"Task: %s, InstructorTask ID: %s, Task type: %s, Preparing for task execution",
xmodule_instance_args.get('task_id'), entry_id, action_name
)
task_fn = partial(generate_students_certificates, xmodule_instance_args)
return run_main_task(entry_id, task_fn, action_name)
@task(base=BaseInstructorTask) # pylint: disable=E1102 @task(base=BaseInstructorTask) # pylint: disable=E1102
def cohort_students(entry_id, xmodule_instance_args): def cohort_students(entry_id, xmodule_instance_args):
""" """
......
...@@ -18,6 +18,7 @@ from celery.states import SUCCESS, FAILURE ...@@ -18,6 +18,7 @@ from celery.states import SUCCESS, FAILURE
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.files.storage import DefaultStorage from django.core.files.storage import DefaultStorage
from django.db import transaction, reset_queries from django.db import transaction, reset_queries
from django.db.models import Q
import dogstats_wrapper as dog_stats_api import dogstats_wrapper as dog_stats_api
from pytz import UTC from pytz import UTC
from StringIO import StringIO from StringIO import StringIO
...@@ -33,7 +34,12 @@ from util.file import course_filename_prefix_generator, UniversalNewlineIterator ...@@ -33,7 +34,12 @@ from util.file import course_filename_prefix_generator, UniversalNewlineIterator
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.split_test_module import get_split_user_partitions from xmodule.split_test_module import get_split_user_partitions
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from certificates.models import CertificateWhitelist, certificate_info_for_user from certificates.models import (
CertificateWhitelist,
certificate_info_for_user,
CertificateStatuses
)
from certificates.api import generate_user_certificates
from courseware.courses import get_course_by_id, get_problems_in_section from courseware.courses import get_course_by_id, get_problems_in_section
from courseware.grades import iterate_grades_for from courseware.grades import iterate_grades_for
from courseware.models import StudentModule from courseware.models import StudentModule
...@@ -50,7 +56,7 @@ from opaque_keys.edx.keys import UsageKey ...@@ -50,7 +56,7 @@ from opaque_keys.edx.keys import UsageKey
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted
from student.models import CourseEnrollment, CourseAccessRole from student.models import CourseEnrollment, CourseAccessRole
from verify_student.models import SoftwareSecurePhotoVerification from verify_student.models import SoftwareSecurePhotoVerification
from util.query import use_read_replica_if_available
# define different loggers for use within tasks and on client side # define different loggers for use within tasks and on client side
TASK_LOG = logging.getLogger('edx.celery.task') TASK_LOG = logging.getLogger('edx.celery.task')
...@@ -1247,6 +1253,44 @@ def upload_exec_summary_report(_xmodule_instance_args, _entry_id, course_id, _ta ...@@ -1247,6 +1253,44 @@ def upload_exec_summary_report(_xmodule_instance_args, _entry_id, course_id, _ta
return task_progress.update_task_state(extra_meta=current_step) return task_progress.update_task_state(extra_meta=current_step)
def generate_students_certificates(
_xmodule_instance_args, _entry_id, course_id, task_input, action_name): # pylint: disable=unused-argument
"""
For a given `course_id`, generate certificates for all students
that are enrolled.
"""
start_time = time()
enrolled_students = use_read_replica_if_available(CourseEnrollment.objects.users_enrolled_in(course_id))
task_progress = TaskProgress(action_name, enrolled_students.count(), start_time)
current_step = {'step': 'Calculating students already have certificates'}
task_progress.update_task_state(extra_meta=current_step)
students_require_certs = students_require_certificate(course_id, enrolled_students)
task_progress.skipped = task_progress.total - len(students_require_certs)
current_step = {'step': 'Generating Certificates'}
task_progress.update_task_state(extra_meta=current_step)
course = modulestore().get_course(course_id, depth=0)
# Generate certificate for each student
for student in students_require_certs:
task_progress.attempted += 1
status = generate_user_certificates(
student,
course_id,
course=course
)
if status in [CertificateStatuses.generating, CertificateStatuses.downloadable]:
task_progress.succeeded += 1
else:
task_progress.failed += 1
return task_progress.update_task_state(extra_meta=current_step)
def cohort_students_and_upload(_xmodule_instance_args, _entry_id, course_id, task_input, action_name): def cohort_students_and_upload(_xmodule_instance_args, _entry_id, course_id, task_input, action_name):
""" """
Within a given course, cohort students in bulk, then upload the results Within a given course, cohort students in bulk, then upload the results
...@@ -1330,3 +1374,17 @@ def cohort_students_and_upload(_xmodule_instance_args, _entry_id, course_id, tas ...@@ -1330,3 +1374,17 @@ def cohort_students_and_upload(_xmodule_instance_args, _entry_id, course_id, tas
upload_csv_to_report_store(output_rows, 'cohort_results', course_id, start_date) upload_csv_to_report_store(output_rows, 'cohort_results', course_id, start_date)
return task_progress.update_task_state(extra_meta=current_step) return task_progress.update_task_state(extra_meta=current_step)
def students_require_certificate(course_id, enrolled_students):
""" Returns list of students where certificates needs to be generated.
Removing those students who have their certificate already generated
from total enrolled students for given course.
:param course_id:
:param enrolled_students:
"""
# compute those students where certificates already generated
students_already_have_certs = use_read_replica_if_available(User.objects.filter(
~Q(generatedcertificate__status=CertificateStatuses.unavailable),
generatedcertificate__course_id=course_id))
return list(set(enrolled_students) - set(students_already_have_certs))
...@@ -18,7 +18,8 @@ from instructor_task.api import ( ...@@ -18,7 +18,8 @@ from instructor_task.api import (
submit_cohort_students, submit_cohort_students,
submit_detailed_enrollment_features_csv, submit_detailed_enrollment_features_csv,
submit_calculate_may_enroll_csv, submit_calculate_may_enroll_csv,
submit_executive_summary_report submit_executive_summary_report,
generate_certificates_for_all_students,
) )
from instructor_task.api_helper import AlreadyRunningError from instructor_task.api_helper import AlreadyRunningError
...@@ -236,3 +237,13 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa ...@@ -236,3 +237,13 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa
file_name=u'filename.csv' file_name=u'filename.csv'
) )
self._test_resubmission(api_call) self._test_resubmission(api_call)
def test_submit_generate_certs_students(self):
"""
Tests certificates generation task submission api
"""
api_call = lambda: generate_certificates_for_all_students(
self.create_task_request(self.instructor),
self.course.id
)
self._test_resubmission(api_call)
...@@ -22,7 +22,12 @@ from student.tests.factories import UserFactory, CourseEnrollmentFactory ...@@ -22,7 +22,12 @@ from student.tests.factories import UserFactory, CourseEnrollmentFactory
from instructor_task.models import InstructorTask from instructor_task.models import InstructorTask
from instructor_task.tests.test_base import InstructorTaskModuleTestCase from instructor_task.tests.test_base import InstructorTaskModuleTestCase
from instructor_task.tests.factories import InstructorTaskFactory from instructor_task.tests.factories import InstructorTaskFactory
from instructor_task.tasks import rescore_problem, reset_problem_attempts, delete_problem_state from instructor_task.tasks import (
rescore_problem,
reset_problem_attempts,
delete_problem_state,
generate_certificates,
)
from instructor_task.tasks_helper import UpdateProblemModuleStateError from instructor_task.tasks_helper import UpdateProblemModuleStateError
PROBLEM_URL_NAME = "test_urlname" PROBLEM_URL_NAME = "test_urlname"
...@@ -97,15 +102,20 @@ class TestInstructorTasks(InstructorTaskModuleTestCase): ...@@ -97,15 +102,20 @@ class TestInstructorTasks(InstructorTaskModuleTestCase):
with self.assertRaises(ItemNotFoundError): with self.assertRaises(ItemNotFoundError):
self._run_task_with_mock_celery(task_class, task_entry.id, task_entry.task_id) self._run_task_with_mock_celery(task_class, task_entry.id, task_entry.task_id)
def _test_run_with_task(self, task_class, action_name, expected_num_succeeded, expected_num_skipped=0): def _test_run_with_task(self, task_class, action_name, expected_num_succeeded,
expected_num_skipped=0, expected_attempted=0, expected_total=0):
"""Run a task and check the number of StudentModules processed.""" """Run a task and check the number of StudentModules processed."""
task_entry = self._create_input_entry() task_entry = self._create_input_entry()
status = self._run_task_with_mock_celery(task_class, task_entry.id, task_entry.task_id) status = self._run_task_with_mock_celery(task_class, task_entry.id, task_entry.task_id)
expected_attempted = expected_attempted \
if expected_attempted else expected_num_succeeded + expected_num_skipped
expected_total = expected_total \
if expected_total else expected_num_succeeded + expected_num_skipped
# check return value # check return value
self.assertEquals(status.get('attempted'), expected_num_succeeded + expected_num_skipped) self.assertEquals(status.get('attempted'), expected_attempted)
self.assertEquals(status.get('succeeded'), expected_num_succeeded) self.assertEquals(status.get('succeeded'), expected_num_succeeded)
self.assertEquals(status.get('skipped'), expected_num_skipped) self.assertEquals(status.get('skipped'), expected_num_skipped)
self.assertEquals(status.get('total'), expected_num_succeeded + expected_num_skipped) self.assertEquals(status.get('total'), expected_total)
self.assertEquals(status.get('action_name'), action_name) self.assertEquals(status.get('action_name'), action_name)
self.assertGreater(status.get('duration_ms'), 0) self.assertGreater(status.get('duration_ms'), 0)
# compare with entry in table: # compare with entry in table:
...@@ -438,3 +448,26 @@ class TestDeleteStateInstructorTask(TestInstructorTasks): ...@@ -438,3 +448,26 @@ class TestDeleteStateInstructorTask(TestInstructorTasks):
StudentModule.objects.get(course_id=self.course.id, StudentModule.objects.get(course_id=self.course.id,
student=student, student=student,
module_state_key=self.location) module_state_key=self.location)
class TestCertificateGenerationnstructorTask(TestInstructorTasks):
"""Tests instructor task that generates student certificates."""
def test_generate_certificates_missing_current_task(self):
"""
Test error is raised when certificate generation task run without current task
"""
self._test_missing_current_task(generate_certificates)
def test_generate_certificates_task_run(self):
"""
Test certificate generation task run without any errors
"""
self._test_run_with_task(
generate_certificates,
'certificates generated',
0,
0,
expected_attempted=1,
expected_total=1
)
...@@ -11,14 +11,13 @@ from mock import Mock, patch ...@@ -11,14 +11,13 @@ from mock import Mock, patch
import tempfile import tempfile
import unicodecsv import unicodecsv
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
from certificates.models import CertificateStatuses
from certificates.tests.factories import GeneratedCertificateFactory, CertificateWhitelistFactory from certificates.tests.factories import GeneratedCertificateFactory, CertificateWhitelistFactory
from course_modes.models import CourseMode from course_modes.models import CourseMode
from courseware.tests.factories import InstructorFactory from courseware.tests.factories import InstructorFactory
from instructor_task.models import ReportStore
from instructor_task.tasks_helper import cohort_students_and_upload, upload_grades_csv, upload_students_csv, \
upload_enrollment_report, upload_exec_summary_report
from instructor_task.tests.test_base import InstructorTaskCourseTestCase, TestReportMixin, InstructorTaskModuleTestCase from instructor_task.tests.test_base import InstructorTaskCourseTestCase, TestReportMixin, InstructorTaskModuleTestCase
from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
...@@ -38,6 +37,9 @@ from instructor_task.tasks_helper import ( ...@@ -38,6 +37,9 @@ from instructor_task.tasks_helper import (
upload_problem_grade_report, upload_problem_grade_report,
upload_students_csv, upload_students_csv,
upload_may_enroll_csv, upload_may_enroll_csv,
upload_enrollment_report,
upload_exec_summary_report,
generate_students_certificates,
) )
from openedx.core.djangoapps.util.testing import ContentGroupTestCase, TestConditionalContent from openedx.core.djangoapps.util.testing import ContentGroupTestCase, TestConditionalContent
...@@ -1300,3 +1302,54 @@ class TestGradeReportEnrollmentAndCertificateInfo(TestReportMixin, InstructorTas ...@@ -1300,3 +1302,54 @@ class TestGradeReportEnrollmentAndCertificateInfo(TestReportMixin, InstructorTas
) )
self._verify_csv_data(user.username, expected_output) self._verify_csv_data(user.username, expected_output)
@override_settings(CERT_QUEUE='test-queue')
class TestCertificateGeneration(InstructorTaskModuleTestCase):
"""
Test certificate generation task works.
"""
def setUp(self):
super(TestCertificateGeneration, self).setUp()
self.initialize_course()
def test_certificate_generation_for_students(self):
"""
Verify that certificates generated for all eligible students enrolled in a course.
"""
# 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'
)
# white-list 5 students
for student in students[2:7]:
CertificateWhitelistFactory.create(user=student, course_id=self.course.id, whitelist=True)
current_task = Mock()
current_task.update_state = Mock()
with self.assertNumQueries(112):
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, None, 'certificates generated')
self.assertDictContainsSubset(
{
'action_name': 'certificates generated',
'total': 10,
'attempted': 8,
'succeeded': 5,
'failed': 3,
'skipped': 2
},
result
)
...@@ -179,6 +179,9 @@ setup_instructor_dashboard_sections = (idash_content) -> ...@@ -179,6 +179,9 @@ setup_instructor_dashboard_sections = (idash_content) ->
, ,
constructor: window.InstructorDashboard.sections.CohortManagement constructor: window.InstructorDashboard.sections.CohortManagement
$element: idash_content.find ".#{CSS_IDASH_SECTION}#cohort_management" $element: idash_content.find ".#{CSS_IDASH_SECTION}#cohort_management"
,
constructor: window.InstructorDashboard.sections.Certificates
$element: idash_content.find ".#{CSS_IDASH_SECTION}#certificates"
] ]
sections_to_initialize.map ({constructor, $element}) -> sections_to_initialize.map ({constructor, $element}) ->
......
var edx = edx || {}; var edx = edx || {};
(function( $, gettext ) { (function($, gettext, _) {
'use strict'; 'use strict';
edx.instructor_dashboard = edx.instructor_dashboard || {}; edx.instructor_dashboard = edx.instructor_dashboard || {};
...@@ -33,5 +33,60 @@ var edx = edx || {}; ...@@ -33,5 +33,60 @@ var edx = edx || {};
$('#refresh-example-certificate-status').on('click', function() { $('#refresh-example-certificate-status').on('click', function() {
window.location.reload(); window.location.reload();
}); });
/**
* Start generating certificates for all students.
*/
var $section = $("section#certificates");
$section.on('click', '#btn-start-generating-certificates', function(event) {
if ( !confirm( gettext('Start generating certificates for all students in this course?') ) ) {
event.preventDefault();
return;
}
var $btn_generating_certs = $(this),$certificate_generation_status = $('.certificate-generation-status');
var url = $btn_generating_certs.data('endpoint');
$.ajax({
type: "POST",
url: url,
success: function (data) {
$btn_generating_certs.attr('disabled','disabled');
$certificate_generation_status.text(data.message);
},
error: function(jqXHR, textStatus, errorThrown) {
$certificate_generation_status.text(gettext('Error while generating certificates. Please try again.'));
}
});
});
});
var Certificates = (function() {
function Certificates($section) {
$section.data('wrapper', this);
this.instructor_tasks = new window.InstructorDashboard.util.PendingInstructorTasks($section);
}
Certificates.prototype.onClickTitle = function() {
return this.instructor_tasks.task_poller.start();
};
Certificates.prototype.onExit = function() {
return this.instructor_tasks.task_poller.stop();
};
return Certificates;
})();
_.defaults(window, {
InstructorDashboard: {}
}); });
})( $, gettext );
_.defaults(window.InstructorDashboard, {
sections: {}
});
_.defaults(window.InstructorDashboard.sections, {
Certificates: Certificates
});
})($, gettext, _);
...@@ -55,4 +55,27 @@ ...@@ -55,4 +55,27 @@
<button class="is-disabled" disabled>${_('Enable Student-Generated Certificates')}</button> <button class="is-disabled" disabled>${_('Enable Student-Generated Certificates')}</button>
% endif % endif
</div> </div>
<hr />
<div class="start-certificate-generation">
<h2>${_("Generate Certificates")}</h2>
<form id="certificates-generating-form" method="post" action="${section_data['urls']['start_certificate_generation']}">
<input type="button" id="btn-start-generating-certificates" value="${_('Generate Certificates')}" data-endpoint="${section_data['urls']['start_certificate_generation']}"/>
</form>
<div class="certificate-generation-status"></div>
</div>
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<div class="running-tasks-container action-type-container">
<hr>
<h2> ${_("Pending Tasks")} </h2>
<div class="running-tasks-section">
<p>${_("The status for any active tasks appears in a table below.")} </p>
<br />
<div class="running-tasks-table" data-endpoint="${ section_data['urls']['list_instructor_tasks_url'] }"></div>
</div>
<div class="no-pending-tasks-message"></div>
</div>
%endif
</div> </div>
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