Commit 99bd47e9 by Saleem Latif

Add Cert Exception UI on Instructor Dash

parent 15d1aa3a
...@@ -995,6 +995,23 @@ class CertificatesPage(PageObject): ...@@ -995,6 +995,23 @@ class CertificatesPage(PageObject):
url = None url = None
PAGE_SELECTOR = 'section#certificates' PAGE_SELECTOR = 'section#certificates'
def wait_for_certificate_exceptions_section(self):
"""
Wait for Certificate Exceptions to be rendered on page
"""
self.wait_for_element_visibility(
'div.certificate_exception-container',
'Certificate Exception Section is visible'
)
self.wait_for_element_visibility('#add-exception', 'Add Exception button is visible')
def refresh(self):
"""
Refresh Certificates Page and wait for the page to load completely.
"""
self.browser.refresh()
self.wait_for_page()
def is_browser_on_page(self): def is_browser_on_page(self):
return self.q(css='a[data-section=certificates].active-section').present return self.q(css='a[data-section=certificates].active-section').present
...@@ -1004,6 +1021,33 @@ class CertificatesPage(PageObject): ...@@ -1004,6 +1021,33 @@ class CertificatesPage(PageObject):
""" """
return self.q(css=' '.join([self.PAGE_SELECTOR, css_selector])) return self.q(css=' '.join([self.PAGE_SELECTOR, css_selector]))
def add_certificate_exception(self, student, free_text_note):
"""
Add Certificate Exception for 'student'.
"""
self.wait_for_element_visibility('#add-exception', 'Add Exception button is visible')
self.get_selector('#certificate-exception').fill(student)
self.get_selector('#notes').fill(free_text_note)
self.get_selector('#add-exception').click()
self.wait_for(
lambda: student in self.get_selector('div.white-listed-students table tr:last-child td').text,
description='Certificate Exception added to list'
)
def click_generate_certificate_exceptions_button(self): # pylint: disable=invalid-name
"""
Click 'Generate Exception Certificates' button in 'Certificates Exceptions' section
"""
self.get_selector('#generate-exception-certificates').click()
def click_add_exception_button(self):
"""
Click 'Add Exception' button in 'Certificates Exceptions' section
"""
self.get_selector('#add-exception').click()
@property @property
def generate_certificates_button(self): def generate_certificates_button(self):
""" """
...@@ -1024,3 +1068,24 @@ class CertificatesPage(PageObject): ...@@ -1024,3 +1068,24 @@ class CertificatesPage(PageObject):
Returns the "Pending Instructor Tasks" section. Returns the "Pending Instructor Tasks" section.
""" """
return self.get_selector('div.running-tasks-container') return self.get_selector('div.running-tasks-container')
@property
def certificate_exceptions_section(self):
"""
Returns the "Certificate Exceptions" section.
"""
return self.get_selector('div.certificate_exception-container')
@property
def last_certificate_exception(self):
"""
Returns the Last Certificate Exception in Certificate Exceptions list in "Certificate Exceptions" section.
"""
return self.get_selector('div.white-listed-students table tr:last-child td')
@property
def message(self):
"""
Returns the Message (error/success) in "Certificate Exceptions" section.
"""
return self.get_selector('div.message')
...@@ -21,6 +21,7 @@ from ...pages.lms.dashboard import DashboardPage ...@@ -21,6 +21,7 @@ from ...pages.lms.dashboard import DashboardPage
from ...pages.lms.problem import ProblemPage from ...pages.lms.problem import ProblemPage
from ...pages.lms.track_selection import TrackSelectionPage from ...pages.lms.track_selection import TrackSelectionPage
from ...pages.lms.pay_and_verify import PaymentAndVerificationFlow, FakePaymentPage from ...pages.lms.pay_and_verify import PaymentAndVerificationFlow, FakePaymentPage
from common.test.acceptance.tests.helpers import disable_animations
class BaseInstructorDashboardTest(EventsTestMixin, UniqueCourseTest): class BaseInstructorDashboardTest(EventsTestMixin, UniqueCourseTest):
...@@ -567,9 +568,10 @@ class CertificatesTest(BaseInstructorDashboardTest): ...@@ -567,9 +568,10 @@ class CertificatesTest(BaseInstructorDashboardTest):
def setUp(self): def setUp(self):
super(CertificatesTest, self).setUp() super(CertificatesTest, self).setUp()
self.course_fixture = CourseFixture(**self.course_info).install() self.course_fixture = CourseFixture(**self.course_info).install()
self.log_in_as_instructor() self.user_name, self.user_id = self.log_in_as_instructor()
instructor_dashboard_page = self.visit_instructor_dashboard() self.instructor_dashboard_page = self.visit_instructor_dashboard()
self.certificates_section = instructor_dashboard_page.select_certificates() self.certificates_section = self.instructor_dashboard_page.select_certificates()
disable_animations(self.certificates_section)
def test_generate_certificates_buttons_is_visible(self): def test_generate_certificates_buttons_is_visible(self):
""" """
...@@ -600,3 +602,112 @@ class CertificatesTest(BaseInstructorDashboardTest): ...@@ -600,3 +602,112 @@ class CertificatesTest(BaseInstructorDashboardTest):
Then I see 'Pending Instructor Tasks' section Then I see 'Pending Instructor Tasks' section
""" """
self.assertTrue(self.certificates_section.pending_tasks_section.visible) self.assertTrue(self.certificates_section.pending_tasks_section.visible)
def test_certificate_exceptions_section_is_visible(self):
"""
Scenario: On the Certificates tab of the Instructor Dashboard, Certificate Exceptions section is visible.
Given that I am on the Certificates tab on the Instructor Dashboard
Then I see 'CERTIFICATE EXCEPTIONS' section
"""
self.assertTrue(self.certificates_section.certificate_exceptions_section.visible)
def test_instructor_can_add_certificate_exception(self):
"""
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor can added new certificate
exception to list
Given that I am on the Certificates tab on the Instructor Dashboard
When I fill in student username and click 'Add Exception' button
Then new certificate exception should be visible in certificate exceptions list
"""
# Add a student to Certificate exception list
self.certificates_section.add_certificate_exception(self.user_name, '')
self.assertIn(self.user_name, self.certificates_section.last_certificate_exception.text)
def test_error_on_duplicate_certificate_exception(self):
"""
Scenario: On the Certificates tab of the Instructor Dashboard,
Error message appears if student being added already exists in certificate exceptions list
Given that I am on the Certificates tab on the Instructor Dashboard
When I fill in student username that already is in the list and click 'Add Exception' button
Then Error Message should say 'username/email already in exception list'
"""
# Add a student to Certificate exception list
self.certificates_section.add_certificate_exception(self.user_name, '')
# Add duplicate student to Certificate exception list
self.certificates_section.add_certificate_exception(self.user_name, '')
self.assertIn(
'username/email already in exception list',
self.certificates_section.message.text
)
def test_error_on_empty_user_name(self):
"""
Scenario: On the Certificates tab of the Instructor Dashboard,
Error message appears if no username/email is entered while clicking "Add Exception" button
Given that I am on the Certificates tab on the Instructor Dashboard
When I click on 'Add Exception' button
AND student username/email field is empty
Then Error Message should say 'Student username/email is required.'
"""
# Click 'Add Exception' button without filling username/email field
self.certificates_section.wait_for_certificate_exceptions_section()
self.certificates_section.click_add_exception_button()
self.assertIn(
'Student username/email is required.',
self.certificates_section.message.text
)
def test_generate_certificate_exception(self):
"""
Scenario: On the Certificates tab of the Instructor Dashboard, when user clicks
'Generate Exception Certificates' newly added certificate exceptions should be synced on server
Given that I am on the Certificates tab on the Instructor Dashboard
When I click 'Generate Exception Certificates'
Then newly added certificate exceptions should be synced on server
"""
# Add a student to Certificate exception list
self.certificates_section.add_certificate_exception(self.user_name, '')
# Click 'Generate Exception Certificates' button
self.certificates_section.click_generate_certificate_exceptions_button()
self.certificates_section.wait_for_ajax()
# Revisit Page
self.certificates_section.refresh()
# wait for the certificate exception section to render
self.certificates_section.wait_for_certificate_exceptions_section()
# validate certificate exception synced with server is visible in certificate exceptions list
self.assertIn(self.user_name, self.certificates_section.last_certificate_exception.text)
def test_invalid_user_on_generate_certificate_exception(self):
"""
Scenario: On the Certificates tab of the Instructor Dashboard, when user clicks
'Generate Exception Certificates' error message should appear if user does not exist
Given that I am on the Certificates tab on the Instructor Dashboard
When I click 'Generate Exception Certificates'
AND the user specified by instructor does not exist
Then an error message "Student (username/email=test_user) does not exist" is displayed
"""
invalid_user = 'test_user_non_existent'
# Add a student to Certificate exception list
self.certificates_section.add_certificate_exception(invalid_user, '')
# Click 'Generate Exception Certificates' button
self.certificates_section.click_generate_certificate_exceptions_button()
self.certificates_section.wait_for_ajax()
# validate certificate exception synced with server is visible in certificate exceptions list
self.assertIn(
'Student (username/email={}) does not exist'.format(invalid_user),
self.certificates_section.message.text
)
...@@ -58,6 +58,7 @@ from django.db.models.signals import post_save ...@@ -58,6 +58,7 @@ from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_extensions.db.fields import CreationDateTimeField
from django_extensions.db.fields.json import JSONField from django_extensions.db.fields.json import JSONField
from model_utils import Choices from model_utils import Choices
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
...@@ -108,6 +109,40 @@ class CertificateWhitelist(models.Model): ...@@ -108,6 +109,40 @@ class CertificateWhitelist(models.Model):
user = models.ForeignKey(User) user = models.ForeignKey(User)
course_id = CourseKeyField(max_length=255, blank=True, default=None) course_id = CourseKeyField(max_length=255, blank=True, default=None)
whitelist = models.BooleanField(default=0) whitelist = models.BooleanField(default=0)
created = CreationDateTimeField(_('created'))
notes = models.TextField(default=None, null=True)
@classmethod
def get_certificate_white_list(cls, course_id):
"""
Return certificate white list for the given course as dict object,
returned dictionary will have the following key-value pairs
[{
id: 'id (pk) of CertificateWhitelist item'
user_id: 'User Id of the student'
user_name: 'name of the student'
user_email: 'email of the student'
course_id: 'Course key of the course to whom certificate exception belongs'
created: 'Creation date of the certificate exception'
notes: 'Additional notes for the certificate exception'
}, {...}, ...]
"""
white_list = cls.objects.filter(course_id=course_id, whitelist=True)
result = []
for item in white_list:
result.append({
'id': item.id,
'user_id': item.user.id,
'user_name': unicode(item.user.username),
'user_email': unicode(item.user.email),
'course_id': unicode(item.course_id),
'created': item.created.strftime("%A, %B %d, %Y"),
'notes': unicode(item.notes or ''),
})
return result
class GeneratedCertificate(models.Model): class GeneratedCertificate(models.Model):
......
...@@ -30,6 +30,7 @@ class CertificateWhitelistFactory(DjangoModelFactory): ...@@ -30,6 +30,7 @@ class CertificateWhitelistFactory(DjangoModelFactory):
course_id = None course_id = None
whitelist = True whitelist = True
notes = None
class BadgeAssertionFactory(DjangoModelFactory): class BadgeAssertionFactory(DjangoModelFactory):
......
...@@ -204,8 +204,19 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): ...@@ -204,8 +204,19 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase):
super(CertificatesInstructorApiTest, self).setUp() super(CertificatesInstructorApiTest, self).setUp()
self.global_staff = GlobalStaffFactory() self.global_staff = GlobalStaffFactory()
self.instructor = InstructorFactory(course_key=self.course.id) self.instructor = InstructorFactory(course_key=self.course.id)
self.user = UserFactory()
# Enable certificate generation # Enable certificate generation
self.certificate_exception_data = [
dict(
created="Wednesday, October 28, 2015",
notes="Test Notes for Test Certificate Exception",
user_email='',
user_id='',
user_name=unicode(self.user.username)
),
]
cache.clear() cache.clear()
CertificateGenerationConfiguration.objects.create(enabled=True) CertificateGenerationConfiguration.objects.create(enabled=True)
...@@ -301,3 +312,138 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): ...@@ -301,3 +312,138 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase):
res_json = json.loads(response.content) res_json = json.loads(response.content)
self.assertIsNotNone(res_json['message']) self.assertIsNotNone(res_json['message'])
self.assertIsNotNone(res_json['task_id']) self.assertIsNotNone(res_json['task_id'])
def test_certificate_exception_added_successfully(self):
"""
Test certificates exception addition api endpoint returns success status and updated certificate exception data
when called with valid course key and certificate exception data
"""
self.client.login(username=self.global_staff.username, password='test')
url = reverse(
'create_certificate_exception',
kwargs={'course_id': unicode(self.course.id), 'white_list_student': ''}
)
response = self.client.post(
url,
data=json.dumps(self.certificate_exception_data),
content_type='application/json'
)
# Assert successful request processing
self.assertEqual(response.status_code, 200)
res_json = json.loads(response.content)
# Assert Request was successful
self.assertTrue(res_json['success'])
# Assert Success Message
self.assertEqual(res_json['message'], u'Students added to Certificate white list successfully')
# Assert Certificate Exception Updated data
certificate_exception = json.loads(res_json['data'])[0]
self.assertEqual(certificate_exception['user_email'], self.user.email)
self.assertEqual(certificate_exception['user_name'], self.user.username)
self.assertEqual(certificate_exception['user_id'], self.user.id) # pylint: disable=no-member
def test_certificate_exception_invalid_username_error(self):
"""
Test certificates exception addition api endpoint returns failure when called with
invalid username.
"""
invalid_user = 'test_invalid_user_name'
self.certificate_exception_data[0].update({'user_name': invalid_user})
self.client.login(username=self.global_staff.username, password='test')
url = reverse(
'create_certificate_exception',
kwargs={'course_id': unicode(self.course.id), 'white_list_student': ''}
)
response = self.client.post(
url,
data=json.dumps(self.certificate_exception_data),
content_type='application/json')
# Assert 400 status code in response
self.assertEqual(response.status_code, 400)
res_json = json.loads(response.content)
# Assert Request not successful
self.assertFalse(res_json['success'])
# Assert Error Message
self.assertEqual(
res_json['message'],
u'Student (username/email={user}) does not exist'.format(user=invalid_user)
)
def test_certificate_exception_missing_username_and_email_error(self):
"""
Test certificates exception addition api endpoint returns failure when called with
missing username/email.
"""
self.certificate_exception_data[0].update({'user_name': '', 'user_email': ''})
self.client.login(username=self.global_staff.username, password='test')
url = reverse(
'create_certificate_exception',
kwargs={'course_id': unicode(self.course.id), 'white_list_student': ''}
)
response = self.client.post(
url,
data=json.dumps(self.certificate_exception_data),
content_type='application/json')
# Assert 400 status code in response
self.assertEqual(response.status_code, 400)
res_json = json.loads(response.content)
# Assert Request not successful
self.assertFalse(res_json['success'])
# Assert Error Message
self.assertEqual(
res_json['message'],
u'Student username/email is required.'
)
def test_certificate_exception_duplicate_user_error(self):
"""
Test certificates exception addition api endpoint returns failure when called with
username/email that already exists in 'CertificateWhitelist' table.
"""
self.client.login(username=self.global_staff.username, password='test')
url = reverse(
'create_certificate_exception',
kwargs={'course_id': unicode(self.course.id), 'white_list_student': ''}
)
self.client.post(
url,
data=json.dumps(self.certificate_exception_data),
content_type='application/json'
)
# Make some request again to simulate duplicate user scenario
response = self.client.post(
url,
data=json.dumps(self.certificate_exception_data),
content_type='application/json'
)
# Assert 400 status code in response
self.assertEqual(response.status_code, 400)
res_json = json.loads(response.content)
# Assert Request not successful
self.assertFalse(res_json['success'])
user = self.certificate_exception_data[0]['user_name']
# Assert Error Message
self.assertEqual(
res_json['message'],
u"Student (username/email={user_id} already in certificate exception list)".format(user_id=user)
)
...@@ -18,6 +18,7 @@ from django.views.decorators.cache import cache_control ...@@ -18,6 +18,7 @@ from django.views.decorators.cache import cache_control
from django.core.exceptions import ValidationError, PermissionDenied from django.core.exceptions import ValidationError, PermissionDenied
from django.core.mail.message import EmailMessage from django.core.mail.message import EmailMessage
from django.db import IntegrityError from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.validators import validate_email from django.core.validators import validate_email
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
...@@ -91,8 +92,10 @@ from instructor.views import INVOICE_KEY ...@@ -91,8 +92,10 @@ from instructor.views import INVOICE_KEY
from submissions import api as sub_api # installed from the edx-submissions repository from submissions import api as sub_api # installed from the edx-submissions repository
from certificates import api as certs_api from certificates import api as certs_api
from certificates.models import CertificateWhitelist
from bulk_email.models import CourseEmail from bulk_email.models import CourseEmail
from student.models import get_user_by_username_or_email
from .tools import ( from .tools import (
dump_student_extensions, dump_student_extensions,
...@@ -2680,3 +2683,100 @@ def start_certificate_generation(request, course_id): ...@@ -2680,3 +2683,100 @@ def start_certificate_generation(request, course_id):
'task_id': task.task_id 'task_id': task.task_id
} }
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 create_certificate_exception(request, course_id, white_list_student=None):
"""
Add Students to certificate white list.
"""
course_key = CourseKey.from_string(course_id)
try:
certificate_white_list = json.loads(request.body)
except ValueError:
return JsonResponse({
'success': False,
'message': _('Invalid Json data')
}, status=400)
try:
certificate_white_list, students = process_certificate_exceptions(certificate_white_list, course_key)
except ValueError as error:
return JsonResponse(
{'success': False, 'message': error.message, 'data': json.dumps(certificate_white_list)},
status=400
)
if white_list_student == 'all':
# Generate Certificates for all white listed students
students = User.objects.filter(
certificatewhitelist__course_id=course_key,
certificatewhitelist__whitelist=True
)
if students:
# generate certificates for students if 'students' list is not empty
instructor_task.api.generate_certificates_for_students(request, course_key, students=students)
response_payload = {
'success': True,
'message': _('Students added to Certificate white list successfully'),
'data': json.dumps(certificate_white_list)
}
return JsonResponse(response_payload)
def process_certificate_exceptions(data_list, course_key):
"""
Validate user data for certificate exceptions, raise ValueError in case of invalid data and create
'CertificateWhitelist' record for students in data_list.
return updated data_list after creating 'CertificateWhitelist' records in db.
"""
students = []
users = [data.get('user_name', False) or data.get('user_email', False) for data in data_list]
if not all(users):
# Username and email can not both be empty
raise ValueError(_('Student username/email is required.'))
if len(users) != len(set(users)):
# Duplicate Student username/email is not allowed
raise ValueError(_('Duplicate Student Username/password.'))
for data in data_list:
user = data.get('user_name', '') or data.get('user_email', '')
try:
db_user = get_user_by_username_or_email(user)
except ObjectDoesNotExist:
raise ValueError(_('Student (username/email={user}) does not exist').format(user=user))
except MultipleObjectsReturned:
raise ValueError(_('Multiple Students found with username/email={user}').format(user=user))
if CertificateWhitelist.objects.filter(user=db_user, whitelist=True).count() > 0:
raise ValueError(
_("Student (username/email={user_id} already in certificate exception list)").format(user_id=user)
)
certificate_white_list = CertificateWhitelist.objects.create(
user=db_user,
course_id=course_key,
whitelist=True,
notes=data.get('notes', '')
)
data.update({
'id': certificate_white_list.id,
'user_email': db_user.email,
'user_name': db_user.username,
'user_id': db_user.id,
'created': certificate_white_list.created.strftime("%A, %B %d, %Y"),
})
students.append(db_user)
return data_list, students
...@@ -140,4 +140,8 @@ urlpatterns = patterns( ...@@ -140,4 +140,8 @@ urlpatterns = patterns(
url(r'^start_certificate_generation', url(r'^start_certificate_generation',
'instructor.views.api.start_certificate_generation', 'instructor.views.api.start_certificate_generation',
name='start_certificate_generation'), name='start_certificate_generation'),
url(r'^create_certificate_exception/(?P<white_list_student>[^/]*)',
'instructor.views.api.create_certificate_exception',
name='create_certificate_exception'),
) )
...@@ -37,7 +37,7 @@ from student.models import CourseEnrollment ...@@ -37,7 +37,7 @@ from student.models import CourseEnrollment
from shoppingcart.models import Coupon, PaidCourseRegistration, CourseRegCodeItem from shoppingcart.models import Coupon, PaidCourseRegistration, CourseRegCodeItem
from course_modes.models import CourseMode, CourseModesArchive from course_modes.models import CourseMode, CourseModesArchive
from student.roles import CourseFinanceAdminRole, CourseSalesAdminRole from student.roles import CourseFinanceAdminRole, CourseSalesAdminRole
from certificates.models import CertificateGenerationConfiguration from certificates.models import CertificateGenerationConfiguration, CertificateWhitelist
from certificates import api as certs_api from certificates import api as certs_api
from util.date_utils import get_default_time_display from util.date_utils import get_default_time_display
...@@ -161,13 +161,21 @@ def instructor_dashboard_2(request, course_id): ...@@ -161,13 +161,21 @@ def instructor_dashboard_2(request, course_id):
disable_buttons = not _is_small_course(course_key) disable_buttons = not _is_small_course(course_key)
certificate_white_list = CertificateWhitelist.get_certificate_white_list(course_key)
certificate_exception_url = reverse(
'create_certificate_exception',
kwargs={'course_id': unicode(course_key), 'white_list_student': ''}
)
context = { context = {
'course': course, 'course': course,
'old_dashboard_url': reverse('instructor_dashboard_legacy', kwargs={'course_id': unicode(course_key)}), 'old_dashboard_url': reverse('instructor_dashboard_legacy', kwargs={'course_id': unicode(course_key)}),
'studio_url': get_studio_url(course, 'course'), 'studio_url': get_studio_url(course, 'course'),
'sections': sections, 'sections': sections,
'disable_buttons': disable_buttons, 'disable_buttons': disable_buttons,
'analytics_dashboard_message': analytics_dashboard_message 'analytics_dashboard_message': analytics_dashboard_message,
'certificate_white_list': certificate_white_list,
'certificate_exception_url': certificate_exception_url
} }
return render_to_response('instructor/instructor_dashboard_2/instructor_dashboard_2.html', context) return render_to_response('instructor/instructor_dashboard_2/instructor_dashboard_2.html', context)
......
...@@ -476,3 +476,24 @@ def generate_certificates_for_all_students(request, course_key): # pylint: dis ...@@ -476,3 +476,24 @@ def generate_certificates_for_all_students(request, course_key): # pylint: dis
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_students(request, course_key, students=None): # pylint: disable=invalid-name
"""
Submits a task to generate certificates for given students enrolled in the course or
all students if argument 'students' is None
Raises AlreadyRunningError if certificates are currently being generated.
"""
if students:
task_type = 'generate_certificates_certain_student'
students = [student.id for student in students]
task_input = {'students': students}
else:
task_type = 'generate_certificates_all_student'
task_input = {}
task_class = generate_certificates
task_key = ""
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
...@@ -1341,11 +1341,17 @@ def upload_proctored_exam_results_report(_xmodule_instance_args, _entry_id, cour ...@@ -1341,11 +1341,17 @@ def upload_proctored_exam_results_report(_xmodule_instance_args, _entry_id, cour
def generate_students_certificates( def generate_students_certificates(
_xmodule_instance_args, _entry_id, course_id, task_input, action_name): # pylint: disable=unused-argument _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 For a given `course_id`, generate certificates for only students present in 'students' key in task_input
that are enrolled. json column, otherwise generate certificates for all enrolled students.
""" """
start_time = time() start_time = time()
enrolled_students = CourseEnrollment.objects.users_enrolled_in(course_id) enrolled_students = CourseEnrollment.objects.users_enrolled_in(course_id)
students = task_input.get('students', None)
if students is not None:
enrolled_students = enrolled_students.filter(id__in=students)
task_progress = TaskProgress(action_name, enrolled_students.count(), start_time) task_progress = TaskProgress(action_name, enrolled_students.count(), start_time)
current_step = {'step': 'Calculating students already have certificates'} current_step = {'step': 'Calculating students already have certificates'}
......
...@@ -9,6 +9,7 @@ Tests that CSV grade report generation works with unicode emails. ...@@ -9,6 +9,7 @@ Tests that CSV grade report generation works with unicode emails.
import ddt import ddt
from mock import Mock, patch from mock import Mock, patch
import tempfile import tempfile
import json
from openedx.core.djangoapps.course_groups import cohorts from openedx.core.djangoapps.course_groups import cohorts
import unicodecsv import unicodecsv
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -1510,12 +1511,18 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): ...@@ -1510,12 +1511,18 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
current_task = Mock() current_task = Mock()
current_task.update_state = Mock() current_task.update_state = Mock()
instructor_task = Mock()
instructor_task.task_input = json.dumps({'students': None})
with self.assertNumQueries(125): with self.assertNumQueries(125):
with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task: with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task:
mock_current_task.return_value = current_task mock_current_task.return_value = current_task
with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_queue: with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_queue:
mock_queue.return_value = (0, "Successfully queued") mock_queue.return_value = (0, "Successfully queued")
result = generate_students_certificates(None, None, self.course.id, None, 'certificates generated') with patch('instructor_task.models.InstructorTask.objects.get') as instructor_task_object:
instructor_task_object.return_value = instructor_task
result = generate_students_certificates(
None, None, self.course.id, {}, 'certificates generated'
)
self.assertDictContainsSubset( self.assertDictContainsSubset(
{ {
'action_name': 'certificates generated', 'action_name': 'certificates generated',
......
// Backbone.js Application Collection: CertificateWhiteList
/*global define, RequireJS */
;(function(define){
'use strict';
define([
'backbone',
'gettext',
'js/certificates/models/certificate_exception'
],
function(Backbone, gettext, CertificateExceptionModel){
var CertificateWhiteList = Backbone.Collection.extend({
model: CertificateExceptionModel,
initialize: function(attrs, options){
this.url = options.url;
},
getModel: function(attrs){
var model = this.findWhere({user_name: attrs.user_name});
if(attrs.user_name && model){
return model;
}
model = this.findWhere({user_email: attrs.user_email});
if(attrs.user_email && model){
return model;
}
return undefined;
},
sync: function(options, appended_url){
var filtered = this.filter(function(model){
return model.isNew();
});
Backbone.sync(
'create',
new CertificateWhiteList(filtered, {url: this.url + appended_url}),
options
);
},
update: function(data){
_.each(data, function(item){
var certificate_exception_model =
this.getModel({user_name: item.user_name, user_email: item.user_email});
certificate_exception_model.set(item);
}, this);
}
});
return CertificateWhiteList;
}
);
}).call(this, define || RequireJS.define);
\ No newline at end of file
// Backbone.js Page Object Factory: Certificates
/*global define, RequireJS */
;(function(define){
'use strict';
define([
'jquery',
'js/certificates/views/certificate_whitelist',
'js/certificates/models/certificate_exception',
'js/certificates/views/certificate_whitelist_editor',
'js/certificates/collections/certificate_whitelist'
],
function($, CertificateWhiteListListView, CertificateExceptionModel, CertificateWhiteListEditorView ,
CertificateWhiteListCollection){
return function(certificate_white_list_json, certificate_exception_url){
var certificateWhiteList = new CertificateWhiteListCollection(JSON.parse(certificate_white_list_json), {
parse: true,
canBeEmpty: true,
url: certificate_exception_url
});
new CertificateWhiteListListView({
collection: certificateWhiteList
}).render();
new CertificateWhiteListEditorView({
collection: certificateWhiteList
}).render();
};
}
);
}).call(this, define || RequireJS.define);
\ No newline at end of file
// Backbone.js Application Model: CertificateWhitelist
/*global define, RequireJS */
;(function(define){
'use strict';
define([
'underscore',
'underscore.string',
'backbone',
'gettext'
],
function(_, str, Backbone, gettext){
return Backbone.Model.extend({
idAttribute: 'id',
defaults: {
user_id: '',
user_name: '',
user_email: '',
created: '',
notes: ''
},
validate: function(attrs){
if (!_.str.trim(attrs.user_name) && !_.str.trim(attrs.user_email)) {
return gettext('Student username/email is required.');
}
}
});
}
);
}).call(this, define || RequireJS.define);
\ No newline at end of file
// Backbone Application View: CertificateWhitelist View
/*global define, RequireJS */
;(function(define){
'use strict';
define([
'jquery',
'underscore',
'gettext',
'backbone'
],
function($, _, gettext, Backbone){
return Backbone.View.extend({
el: "#white-listed-students",
generate_exception_certificates_radio:
'input:radio[name=generate-exception-certificates-radio]:checked',
events: {
'click #generate-exception-certificates': 'generateExceptionCertificates'
},
initialize: function(){
// Re-render the view when an item is added to the collection
this.listenTo(this.collection, 'change add', this.render);
},
render: function(){
var template = this.loadTemplate('certificate-white-list');
this.$el.html(template({certificates: this.collection.models}));
},
loadTemplate: function(name) {
var templateSelector = "#" + name + "-tpl",
templateText = $(templateSelector).text();
return _.template(templateText);
},
generateExceptionCertificates: function(){
this.collection.sync(
{success: this.showSuccess(this), error: this.showError(this)},
$(this.generate_exception_certificates_radio).val()
);
},
showSuccess: function(caller_object){
return function(xhr){
var response = xhr;
$(".message").text(response.message).removeClass('msg-error').addClass('msg-success').focus();
caller_object.collection.update(JSON.parse(response.data));
$('html, body').animate({
scrollTop: $("#certificate-exception").offset().top - 10
}, 1000);
};
},
showError: function(caller_object){
return function(xhr){
var response = JSON.parse(xhr.responseText);
$(".message").text(response.message).removeClass('msg-success').addClass("msg-error").focus();
caller_object.collection.update(JSON.parse(response.data));
$('html, body').animate({
scrollTop: $("#certificate-exception").offset().top - 10
}, 1000);
};
}
});
}
);
}).call(this, define || RequireJS.define);
\ No newline at end of file
// Backbone Application View: CertificateWhiteList Editor View
/*global define, RequireJS */
;(function(define){
'use strict';
define([
'jquery',
'underscore',
'gettext',
'backbone',
'js/certificates/models/certificate_exception'
],
function($, _, gettext, Backbone, CertificateExceptionModel){
return Backbone.View.extend({
el: "#certificate-white-list-editor",
message_div: '.message',
events: {
'click #add-exception': 'addException'
},
render: function(){
var template = this.loadTemplate('certificate-white-list-editor');
this.$el.html(template());
},
loadTemplate: function(name) {
var templateSelector = "#" + name + "-tpl",
templateText = $(templateSelector).text();
return _.template(templateText);
},
addException: function(){
var value = this.$("#certificate-exception").val();
var notes = this.$("#notes").val();
var user_email = '', user_name='', model={};
if(this.isEmailAddress(value)){
user_email = value;
model = {user_email: user_email};
}
else{
user_name = value;
model = {user_name: user_name};
}
var certificate_exception = new CertificateExceptionModel({
user_name: user_name,
user_email: user_email,
notes: notes
});
if(this.collection.findWhere(model)){
this.showMessage("username/email already in exception list", 'msg-error');
}
else if(certificate_exception.isValid()){
this.collection.add(certificate_exception, {validate: true});
this.showMessage("Student Added to exception list", 'msg-success');
}
else{
this.showMessage(certificate_exception.validationError, 'msg-error');
}
},
isEmailAddress: function validateEmail(email) {
var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
return re.test(email);
},
showMessage: function(message, messageClass){
this.$(this.message_div).text(message).
removeClass('msg-error msg-success').addClass(messageClass).focus();
$('html, body').animate({
scrollTop: this.$el.offset().top - 20
}, 1000);
}
});
}
);
}).call(this, define || RequireJS.define);
\ No newline at end of file
...@@ -642,6 +642,7 @@ ...@@ -642,6 +642,7 @@
'lms/include/js/spec/shoppingcart/shoppingcart_spec.js', 'lms/include/js/spec/shoppingcart/shoppingcart_spec.js',
'lms/include/js/spec/instructor_dashboard/ecommerce_spec.js', 'lms/include/js/spec/instructor_dashboard/ecommerce_spec.js',
'lms/include/js/spec/instructor_dashboard/student_admin_spec.js', 'lms/include/js/spec/instructor_dashboard/student_admin_spec.js',
'lms/include/js/spec/instructor_dashboard/certificates_exception_spec.js',
'lms/include/js/spec/student_account/account_spec.js', 'lms/include/js/spec/student_account/account_spec.js',
'lms/include/js/spec/student_account/access_spec.js', 'lms/include/js/spec/student_account/access_spec.js',
'lms/include/js/spec/student_account/logistration_factory_spec.js', 'lms/include/js/spec/student_account/logistration_factory_spec.js',
......
...@@ -2119,3 +2119,84 @@ input[name="subject"] { ...@@ -2119,3 +2119,84 @@ input[name="subject"] {
@include left(2em); @include left(2em);
@include right(auto); @include right(auto);
} }
#certificate-white-list-editor{
.msg-success{
border-top: 2px solid $confirm-color;
background: tint($confirm-color,95%);
color: $confirm-color;
}
.certificate-exception-inputs{
.student-username-or-email{
width: 300px;
}
.notes-field{
width: 400px;
}
p+p{
margin-top: 5px;
}
.message{
margin-top: 10px;
}
}
}
.white-listed-students {
table {
width: 100%;
word-wrap: break-word;
th {
@extend %t-copy-sub2;
background-color: $gray-l5;
padding: ($baseline*0.75) ($baseline/2) ($baseline*0.75) ($baseline/2);
vertical-align: middle;
text-align: left;
color: $gray;
&.date-column{
width: 230px;
}
}
td {
padding: ($baseline/2);
vertical-align: middle;
text-align: left;
}
tbody {
box-shadow: 0 2px 2px $shadow-l1;
border: 1px solid $gray-l4;
background: $white;
tr {
@include transition(all $tmg-f2 ease-in-out 0s);
border-top: 1px solid $gray-l4;
&:first-child {
border-top: none;
}
&:nth-child(odd) {
background-color: $gray-l6;
}
a {
color: $gray-d1;
&:hover {
color: $blue;
}
}
&:hover {
background-color: $blue-l5;
}
}
}
}
}
<div class='certificate-exception-inputs'>
<input class='student-username-or-email' id="certificate-exception" type="text" placeholder="Student email or username" aria-describedby='student-user-name-or-email-tip'>
<textarea class='notes-field' id="notes" rows="10" placeholder="Free text notes" aria-describedby='notes-field-tip'></textarea>
<input type="button" id="add-exception" value="Add Exception">
<p id='student-user-name-or-email-tip'><%- gettext("Specify either Student's username or email for whom to create certificate exception") %></p>
<p id='notes-field-tip'><%- gettext("Enter Notes associated with this certificate exception") %></p>
<div class='message'></div>
</div>
<% if (certificates.length === 0) { %>
<p><%- gettext("No results") %></p>
<% } else { %>
<table>
<thead>
<th><%- gettext("Name") %></th>
<th><%- gettext("User ID") %></th>
<th><%- gettext("User Email") %></th>
<th class='date-column'><%- gettext("Date Exception Granted") %></th>
<th><%- gettext("Notes") %></th>
</thead>
<tbody>
<% for (var i = 0; i < certificates.length; i++) {
var cert = certificates[i];
%>
<tr>
<td><%- cert.get("user_name") %></td>
<td><%- cert.get("user_id") %></td>
<td><%- cert.get("user_email") %></td>
<td><%- cert.get("created") %></td>
<td><%- cert.get("notes") %></td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
<br/>
<label>
<input type='radio' name='generate-exception-certificates-radio' checked="checked" value='new' aria-describedby='generate-exception-certificates-radio-new-tip'>
<span id='generate-exception-certificates-radio-new-tip'><%- gettext('Generate a Certificate for all ') %><strong><%- gettext('New') %></strong> <%- gettext('additions to the Exception list') %></span>
</label>
<br/>
<label>
<input type='radio' name='generate-exception-certificates-radio' value='all' aria-describedby='generate-exception-certificates-radio-all-tip'>
<span id='generate-exception-certificates-radio-all-tip'><%- gettext('Generate a Certificate for all users on the Exception list') %></span>
</label>
<br/>
<input type="button" id="generate-exception-certificates" value="<%- gettext('Generate Exception Certificates') %>" />
<%! from django.utils.translation import ugettext as _ %> <%namespace name='static' file='../../static_content.html'/>
<%! from django.utils.translation import ugettext as _
import json
%>
<%static:require_module module_name="js/certificates/factories/certificate_whitelist_factory" class_name="CertificateWhitelistFactory">
CertificateWhitelistFactory('${json.dumps(certificate_white_list)}', "${certificate_exception_url}");
</%static:require_module>
<%page args="section_data"/> <%page args="section_data"/>
<div class="certificates-wrapper"> <div class="certificates-wrapper">
...@@ -82,4 +91,21 @@ ...@@ -82,4 +91,21 @@
</div> </div>
%endif %endif
% endif % endif
<div class="certificate_exception-container">
<hr>
<h2> ${_("Certificate Exceptions")} </h2>
<div class="certificate-exception-section">
<p>${_("Use this to generate certificates for users who did not pass the course but have been given an exception by the Course Team to earn a certificate.")} </p>
<br />
<div id="certificate-white-list-editor"></div>
<br/>
<br/>
<div class="white-listed-students" id="white-listed-students"></div>
<br/>
<br/>
</div>
<div class="no-pending-tasks-message"></div>
</div>
</div> </div>
...@@ -66,7 +66,7 @@ from django.core.urlresolvers import reverse ...@@ -66,7 +66,7 @@ from django.core.urlresolvers import reverse
## Include Underscore templates ## Include Underscore templates
<%block name="header_extras"> <%block name="header_extras">
% for template_name in ["cohorts", "enrollment-code-lookup-links", "cohort-editor", "cohort-group-header", "cohort-selector", "cohort-form", "notification", "cohort-state", "cohort-discussions-inline", "cohort-discussions-course-wide", "cohort-discussions-category","cohort-discussions-subcategory"]: % for template_name in ["cohorts", "enrollment-code-lookup-links", "cohort-editor", "cohort-group-header", "cohort-selector", "cohort-form", "notification", "cohort-state", "cohort-discussions-inline", "cohort-discussions-course-wide", "cohort-discussions-category","cohort-discussions-subcategory","certificate-white-list","certificate-white-list-editor"]:
<script type="text/template" id="${template_name}-tpl"> <script type="text/template" id="${template_name}-tpl">
<%static:include path="instructor/instructor_dashboard_2/${template_name}.underscore" /> <%static:include path="instructor/instructor_dashboard_2/${template_name}.underscore" />
</script> </script>
......
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