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):
student_admin_section.wait_for_page()
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
def get_asset_path(file_name):
"""
......@@ -884,3 +893,41 @@ class StudentAdminPage(PageObject):
"""
input_box = self.student_email_input.first.results[0]
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):
"""
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')
@property
......@@ -56,6 +60,10 @@ class SettingsPage(CoursePage):
"""
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')
@property
......@@ -64,6 +72,10 @@ class SettingsPage(CoursePage):
Returns the alert confirmation element, which contains text
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')
@property
......
......@@ -4,6 +4,7 @@ End-to-end tests for the LMS Instructor Dashboard.
"""
from nose.plugins.attrib import attr
from bok_choy.promise import EmptyPromise
from ..helpers import UniqueCourseTest, get_modal_alert, EventsTestMixin
from ...pages.common.logout import LogoutPage
......@@ -396,3 +397,46 @@ class DataDownloadsTest(BaseInstructorDashboardTest):
self.data_download_section.wait_for_available_report()
self.verify_report_requested_event(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,
"model": "certificates.certificategenerationconfiguration",
"fields": {
"change_date": "2015-06-18 11:02:13",
"changed_by": 99,
"enabled": true
}
},
{
"pk": 99,
"model": "auth.user",
"fields": {
"date_joined": "2015-06-12 11:02:13",
......
......@@ -26,7 +26,8 @@ from certificates.queue import XQueueCertInterface
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.
......@@ -45,12 +46,17 @@ def generate_user_certificates(student, course_key, course=None, insecure=False,
insecure - (Boolean)
generation_mode - who has requested certificate generation. Its value should `batch`
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()
if insecure:
xqueue.use_https = False
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]:
emit_certificate_event('created', student, course_key, course, {
'user_id': student.id,
......
......@@ -2,13 +2,15 @@
import contextlib
import ddt
import mock
import json
from nose.plugins.attrib import attr
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
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 import api as certs_api
......@@ -222,3 +224,39 @@ class CertificatesInstructorApiTest(ModuleStoreTestCase):
)
expected_redirect += '#view-certificates'
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=
'message': message,
}
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(
url(r'^enable_certificate_generation$',
'instructor.views.api.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):
'enable_certificate_generation': reverse(
'enable_certificate_generation',
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 (
cohort_students,
enrollment_report_features_csv,
calculate_may_enroll_csv,
exec_summary_report_csv
exec_summary_report_csv,
generate_certificates,
)
from instructor_task.api_helper import (
......@@ -419,3 +420,17 @@ def submit_cohort_students(request, course_key, file_name):
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 (
cohort_students_and_upload,
upload_enrollment_report,
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):
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
def cohort_students(entry_id, xmodule_instance_args):
"""
......
......@@ -18,6 +18,7 @@ from celery.states import SUCCESS, FAILURE
from django.contrib.auth.models import User
from django.core.files.storage import DefaultStorage
from django.db import transaction, reset_queries
from django.db.models import Q
import dogstats_wrapper as dog_stats_api
from pytz import UTC
from StringIO import StringIO
......@@ -33,7 +34,12 @@ from util.file import course_filename_prefix_generator, UniversalNewlineIterator
from xmodule.modulestore.django import modulestore
from xmodule.split_test_module import get_split_user_partitions
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.grades import iterate_grades_for
from courseware.models import StudentModule
......@@ -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 student.models import CourseEnrollment, CourseAccessRole
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
TASK_LOG = logging.getLogger('edx.celery.task')
......@@ -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)
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):
"""
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
upload_csv_to_report_store(output_rows, 'cohort_results', course_id, start_date)
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 (
submit_cohort_students,
submit_detailed_enrollment_features_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
......@@ -236,3 +237,13 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa
file_name=u'filename.csv'
)
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
from instructor_task.models import InstructorTask
from instructor_task.tests.test_base import InstructorTaskModuleTestCase
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
PROBLEM_URL_NAME = "test_urlname"
......@@ -97,15 +102,20 @@ class TestInstructorTasks(InstructorTaskModuleTestCase):
with self.assertRaises(ItemNotFoundError):
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."""
task_entry = self._create_input_entry()
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
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('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.assertGreater(status.get('duration_ms'), 0)
# compare with entry in table:
......@@ -438,3 +448,26 @@ class TestDeleteStateInstructorTask(TestInstructorTasks):
StudentModule.objects.get(course_id=self.course.id,
student=student,
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
import tempfile
import unicodecsv
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
from certificates.models import CertificateStatuses
from certificates.tests.factories import GeneratedCertificateFactory, CertificateWhitelistFactory
from course_modes.models import CourseMode
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 openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
......@@ -38,6 +37,9 @@ from instructor_task.tasks_helper import (
upload_problem_grade_report,
upload_students_csv,
upload_may_enroll_csv,
upload_enrollment_report,
upload_exec_summary_report,
generate_students_certificates,
)
from openedx.core.djangoapps.util.testing import ContentGroupTestCase, TestConditionalContent
......@@ -1300,3 +1302,54 @@ class TestGradeReportEnrollmentAndCertificateInfo(TestReportMixin, InstructorTas
)
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) ->
,
constructor: window.InstructorDashboard.sections.CohortManagement
$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}) ->
......
var edx = edx || {};
(function( $, gettext ) {
(function($, gettext, _) {
'use strict';
edx.instructor_dashboard = edx.instructor_dashboard || {};
......@@ -33,5 +33,60 @@ var edx = edx || {};
$('#refresh-example-certificate-status').on('click', function() {
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 @@
<button class="is-disabled" disabled>${_('Enable Student-Generated Certificates')}</button>
% endif
</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>
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