diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index f788c7fd3d55d5c445db40533246150a4cd15737..acd150766fc3ba0197ff426ccca9ccd2a26cdf69 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -157,38 +157,43 @@ class CourseEndingTest(TestCase): {'status': 'processing', 'show_disabled_download_button': False, 'show_download_url': False, - 'show_survey_button': False, }) + 'show_survey_button': False, + }) cert_status = {'status': 'unavailable'} self.assertEqual(_cert_info(user, course, cert_status), {'status': 'processing', 'show_disabled_download_button': False, 'show_download_url': False, - 'show_survey_button': False}) + 'show_survey_button': False, + 'mode': None + }) - cert_status = {'status': 'generating', 'grade': '67'} + cert_status = {'status': 'generating', 'grade': '67', 'mode': 'honor'} self.assertEqual(_cert_info(user, course, cert_status), {'status': 'generating', 'show_disabled_download_button': True, 'show_download_url': False, 'show_survey_button': True, 'survey_url': survey_url, - 'grade': '67' + 'grade': '67', + 'mode': 'honor' }) - cert_status = {'status': 'regenerating', 'grade': '67'} + cert_status = {'status': 'regenerating', 'grade': '67', 'mode': 'verified'} self.assertEqual(_cert_info(user, course, cert_status), {'status': 'generating', 'show_disabled_download_button': True, 'show_download_url': False, 'show_survey_button': True, 'survey_url': survey_url, - 'grade': '67' + 'grade': '67', + 'mode': 'verified' }) download_url = 'http://s3.edx/cert' cert_status = {'status': 'downloadable', 'grade': '67', - 'download_url': download_url} + 'download_url': download_url, 'mode': 'honor'} self.assertEqual(_cert_info(user, course, cert_status), {'status': 'ready', 'show_disabled_download_button': False, @@ -196,30 +201,33 @@ class CourseEndingTest(TestCase): 'download_url': download_url, 'show_survey_button': True, 'survey_url': survey_url, - 'grade': '67' + 'grade': '67', + 'mode': 'honor' }) cert_status = {'status': 'notpassing', 'grade': '67', - 'download_url': download_url} + 'download_url': download_url, 'mode': 'honor'} self.assertEqual(_cert_info(user, course, cert_status), {'status': 'notpassing', 'show_disabled_download_button': False, 'show_download_url': False, 'show_survey_button': True, 'survey_url': survey_url, - 'grade': '67' + 'grade': '67', + 'mode': 'honor' }) # Test a course that doesn't have a survey specified course2 = Mock(end_of_course_survey_url=None) cert_status = {'status': 'notpassing', 'grade': '67', - 'download_url': download_url} + 'download_url': download_url, 'mode': 'honor'} self.assertEqual(_cert_info(user, course2, cert_status), {'status': 'notpassing', 'show_disabled_download_button': False, 'show_download_url': False, 'show_survey_button': False, - 'grade': '67' + 'grade': '67', + 'mode': 'honor' }) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 1702d7145e8371f96d19b356313bd2c9fb82f964..81aa8595630481d4b4f7fa750e19d331e83d23bb 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -185,7 +185,8 @@ def _cert_info(user, course, cert_status): default_info = {'status': default_status, 'show_disabled_download_button': False, 'show_download_url': False, - 'show_survey_button': False} + 'show_survey_button': False, + } if cert_status is None: return default_info @@ -203,7 +204,8 @@ def _cert_info(user, course, cert_status): d = {'status': status, 'show_download_url': status == 'ready', - 'show_disabled_download_button': status == 'generating', } + 'show_disabled_download_button': status == 'generating', + 'mode': cert_status.get('mode', None)} if (status in ('generating', 'ready', 'notpassing', 'restricted') and course.end_of_course_survey_url is not None): @@ -296,7 +298,7 @@ def complete_course_mode_info(course_id, enrollment): def dashboard(request): user = request.user - # Build our (course, enorllment) list for the user, but ignore any courses that no + # Build our (course, enrollment) list for the user, but ignore any courses that no # longer exist (because the course IDs have changed). Still, we don't delete those # enrollments, because it could have been a data push snafu. course_enrollment_pairs = [] @@ -1512,4 +1514,4 @@ def change_email_settings(request): log.info(u"User {0} ({1}) opted out of receiving emails from course {2}".format(user.username, user.email, course_id)) track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard') - return HttpResponse(json.dumps({'success': True})) \ No newline at end of file + return HttpResponse(json.dumps({'success': True})) diff --git a/lms/djangoapps/certificates/management/commands/ungenerated_certs.py b/lms/djangoapps/certificates/management/commands/ungenerated_certs.py index 5fb9c53718c3580b370954013a1ce8544a4042e3..5aa223acabb581ab33c5c35d0fbb91f120c21370 100644 --- a/lms/djangoapps/certificates/management/commands/ungenerated_certs.py +++ b/lms/djangoapps/certificates/management/commands/ungenerated_certs.py @@ -93,6 +93,7 @@ class Command(BaseCommand): total = enrolled_students.count() count = 0 start = datetime.datetime.now(UTC) + for student in enrolled_students: count += 1 if count % STATUS_INTERVAL == 0: diff --git a/lms/djangoapps/certificates/migrations/0015_adding_mode_for_verified_certs.py b/lms/djangoapps/certificates/migrations/0015_adding_mode_for_verified_certs.py new file mode 100644 index 0000000000000000000000000000000000000000..c16d51b8ee4e73d036a5a550ba23bc0e1584f381 --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0015_adding_mode_for_verified_certs.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'GeneratedCertificate.mode' + db.add_column('certificates_generatedcertificate', 'mode', + self.gf('django.db.models.fields.CharField')(default='honor', max_length=32), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'GeneratedCertificate.mode' + db.delete_column('certificates_generatedcertificate', 'mode') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'certificates.certificatewhitelist': { + 'Meta': {'object_name': 'CertificateWhitelist'}, + 'course_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'whitelist': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'certificates.generatedcertificate': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'GeneratedCertificate'}, + 'course_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'blank': 'True'}), + 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now_add': 'True', 'blank': 'True'}), + 'distinction': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'download_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '128', 'blank': 'True'}), + 'download_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'error_reason': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}), + 'grade': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '5', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '32'}), + 'modified_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'unavailable'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'verify_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + } + } + + complete_apps = ['certificates'] \ No newline at end of file diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 8cd1a292c4b87e0e85a921a81056f034d700cb70..eb6ca407a0a93c992aca200d71202d6fad7e84a8 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -1,6 +1,7 @@ from django.contrib.auth.models import User from django.db import models from datetime import datetime +from model_utils import Choices """ Certificates are created for a student and an offering of a course. @@ -62,7 +63,6 @@ class CertificateStatuses(object): restricted = 'restricted' unavailable = 'unavailable' - class CertificateWhitelist(models.Model): """ Tracks students who are whitelisted, all users @@ -86,11 +86,13 @@ class GeneratedCertificate(models.Model): key = models.CharField(max_length=32, blank=True, default='') distinction = models.BooleanField(default=False) status = models.CharField(max_length=32, default='unavailable') + MODES = Choices('verified', 'honor', 'audit') + mode = models.CharField(max_length=32, choices=MODES, default=MODES.honor) name = models.CharField(blank=True, max_length=255) created_date = models.DateTimeField( - auto_now_add=True, default=datetime.now) + auto_now_add=True, default=datetime.now) modified_date = models.DateTimeField( - auto_now=True, default=datetime.now) + auto_now=True, default=datetime.now) error_reason = models.CharField(max_length=512, blank=True, default='') class Meta: @@ -128,8 +130,9 @@ def certificate_status_for_student(student, course_id): try: generated_certificate = GeneratedCertificate.objects.get( - user=student, course_id=course_id) - d = {'status': generated_certificate.status} + user=student, course_id=course_id) + d = {'status': generated_certificate.status, + 'mode': generated_certificate.mode} if generated_certificate.grade: d['grade'] = generated_certificate.grade if generated_certificate.status == CertificateStatuses.downloadable: @@ -138,4 +141,4 @@ def certificate_status_for_student(student, course_id): return d except GeneratedCertificate.DoesNotExist: pass - return {'status': CertificateStatuses.unavailable} + return {'status': CertificateStatuses.unavailable, 'mode': GeneratedCertificate.MODES.honor} diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 5f63bbf1e2fcf7119fe498d27cf8ff0159fd592d..2f9e70517a167a78630f1273191ac6aa0df578c3 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -9,7 +9,8 @@ from capa.xqueue_interface import XQueueInterface from capa.xqueue_interface import make_xheader, make_hashkey from django.conf import settings from requests.auth import HTTPBasicAuth -from student.models import UserProfile +from student.models import UserProfile, CourseEnrollment +from verify_student.models import SoftwareSecurePhotoVerification import json import random @@ -57,7 +58,7 @@ class XQueueCertInterface(object): if settings.XQUEUE_INTERFACE.get('basic_auth') is not None: requests_auth = HTTPBasicAuth( - *settings.XQUEUE_INTERFACE['basic_auth']) + *settings.XQUEUE_INTERFACE['basic_auth']) else: requests_auth = None @@ -68,10 +69,10 @@ class XQueueCertInterface(object): self.request = request self.xqueue_interface = XQueueInterface( - settings.XQUEUE_INTERFACE['url'], - settings.XQUEUE_INTERFACE['django_auth'], - requests_auth, - ) + settings.XQUEUE_INTERFACE['url'], + settings.XQUEUE_INTERFACE['django_auth'], + requests_auth, + ) self.whitelist = CertificateWhitelist.objects.all() self.restricted = UserProfile.objects.filter(allow_certificate=False) self.use_https = True @@ -84,7 +85,7 @@ class XQueueCertInterface(object): course_id - courseenrollment.course_id (string) WARNING: this command will leave the old certificate, if one exists, - laying around in AWS taking up space. If this is a problem, + laying around in AWS taking up space. If this is a problem, take pains to clean up storage before running this command. Change the certificate status to unavailable (if it exists) and request @@ -92,7 +93,7 @@ class XQueueCertInterface(object): Return the status object. """ - # TODO: when del_cert is implemented and plumbed through certificates + # TODO: when del_cert is implemented and plumbed through certificates # repo also, do a deletion followed by a creation r/t a simple # recreation. XXX: this leaves orphan cert files laying around in # AWS. See note in the docstring too. @@ -149,13 +150,15 @@ class XQueueCertInterface(object): """ VALID_STATUSES = [status.generating, - status.unavailable, - status.deleted, + status.unavailable, + status.deleted, status.error, status.notpassing] cert_status = certificate_status_for_student(student, course_id)['status'] + new_status = cert_status + if cert_status in VALID_STATUSES: # grade the student @@ -165,9 +168,6 @@ class XQueueCertInterface(object): course = courses.get_course_by_id(course_id) profile = UserProfile.objects.get(user=student) - cert, created = GeneratedCertificate.objects.get_or_create( - user=student, course_id=course_id) - # Needed self.request.user = student self.request.session = {} @@ -175,45 +175,64 @@ class XQueueCertInterface(object): grade = grades.grade(student, self.request, course) is_whitelisted = self.whitelist.filter( user=student, course_id=course_id, whitelist=True).exists() + enrollment_mode = CourseEnrollment.enrollment_mode_for_user(student, course_id) + org = course_id.split('/')[0] + course_num = course_id.split('/')[1] + cert_mode = enrollment_mode + if enrollment_mode == GeneratedCertificate.MODES.verified and SoftwareSecurePhotoVerification.user_is_verified(student): + template_pdf = "certificate-template-{0}-{1}-verified.pdf".format( + org, course_num) + elif (enrollment_mode == GeneratedCertificate.MODES.verified and not + SoftwareSecurePhotoVerification.user_is_verified(student)): + template_pdf = "certificate-template-{0}-{1}.pdf".format( + org, course_num) + cert_mode = GeneratedCertificate.MODES.honor + else: + # honor code and audit students + template_pdf = "certificate-template-{0}-{1}.pdf".format( + org, course_num) - if is_whitelisted or grade['grade'] is not None: + cert, created = GeneratedCertificate.objects.get_or_create( + user=student, course_id=course_id) - key = make_hashkey(random.random()) + cert.mode = cert_mode + cert.user = student + cert.grade = grade['percent'] + cert.course_id = course_id + cert.name = profile.name - cert.grade = grade['percent'] - cert.user = student - cert.course_id = course_id - cert.key = key - cert.name = profile.name + if is_whitelisted or grade['grade'] is not None: # check to see whether the student is on the # the embargoed country restricted list # otherwise, put a new certificate request # on the queue + if self.restricted.filter(user=student).exists(): - cert.status = status.restricted + new_status = status.restricted + cert.status = new_status cert.save() else: + key = make_hashkey(random.random()) + cert.key = key contents = { 'action': 'create', 'username': student.username, 'course_id': course_id, 'name': profile.name, 'grade': grade['grade'], + 'template_pdf': template_pdf, } - cert.status = status.generating + new_status = status.generating + cert.status = new_status cert.save() self._send_to_xqueue(contents, key) else: - cert_status = status.notpassing - cert.grade = grade['percent'] - cert.user = student - cert.course_id = course_id - cert.name = profile.name - cert.status = cert_status + new_status = status.notpassing + cert.status = new_status cert.save() - return cert_status + return new_status def _send_to_xqueue(self, contents, key): @@ -227,7 +246,7 @@ class XQueueCertInterface(object): proto, settings.SITE_NAME, key), key, settings.CERT_QUEUE) (error, msg) = self.xqueue_interface.send_to_queue( - header=xheader, body=json.dumps(contents)) + header=xheader, body=json.dumps(contents)) if error: logger.critical('Unable to add a request to the queue: {} {}'.format(error, msg)) raise Exception('Unable to send queue message') diff --git a/lms/templates/dashboard/_dashboard_certificate_information.html b/lms/templates/dashboard/_dashboard_certificate_information.html index ea5171c0ef9e9722dc0e1f04f84d04a2dbb92de2..3222b6aae854ca22e0cbadfb0f85b2f7c1e7fbc5 100644 --- a/lms/templates/dashboard/_dashboard_certificate_information.html +++ b/lms/templates/dashboard/_dashboard_certificate_information.html @@ -19,7 +19,7 @@ else: % elif cert_status['status'] in ('generating', 'ready', 'notpassing', 'restricted'): <p class="message-copy">${_("Your final grade:")} <span class="grade-value">${"{0:.0f}%".format(float(cert_status['grade'])*100)}</span>. - % if cert_status['status'] == 'notpassing': + % if cert_status['status'] == 'notpassing' and enrollment.mode != 'audit': ${_("Grade required for a certificate:")} <span class="grade-value"> ${"{0:.0f}%".format(float(course.lowest_passing_grade)*100)}</span>. % elif cert_status['status'] == 'restricted' and enrollment.mode == 'verified': @@ -44,6 +44,12 @@ else: <a class="btn" href="${cert_status['download_url']}" title="${_('This link will open/download a PDF document')}"> ${_("Download Your Certificate (PDF)")}</a></li> + % elif cert_status['show_download_url'] and enrollment.mode == 'verified' and cert_status['mode'] == 'honor': + <li class="action"> + <p>${_('Since we did not have a valid set of verification photos from you when certificates were generated, we could not grant you a verified certificate. An honor code certificate has been granted instead.')}</p> + <a class="btn" href="${cert_status['download_url']}" + title="${_('This link will open/download a PDF document')}"> + ${_("Download Your Certificate (PDF)")}</a></li> % elif cert_status['show_download_url'] and enrollment.mode == 'verified': <li class="action"> <a class="btn" href="${cert_status['download_url']}"