Merge pull request #1604 from edx/jarv/verified-certs
Jarv/verified certs
This commit is contained in:
@@ -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})
|
||||
|
||||
cert_status = {'status': 'generating', 'grade': '67'}
|
||||
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'
|
||||
'show_survey_button': False,
|
||||
'mode': None
|
||||
})
|
||||
|
||||
cert_status = {'status': 'regenerating', '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', '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',
|
||||
'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'
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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}))
|
||||
return HttpResponse(json.dumps({'success': True}))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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']
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
cert, created = GeneratedCertificate.objects.get_or_create(
|
||||
user=student, course_id=course_id)
|
||||
|
||||
cert.mode = cert_mode
|
||||
cert.user = student
|
||||
cert.grade = grade['percent']
|
||||
cert.course_id = course_id
|
||||
cert.name = profile.name
|
||||
|
||||
if is_whitelisted or grade['grade'] is not None:
|
||||
|
||||
key = make_hashkey(random.random())
|
||||
|
||||
cert.grade = grade['percent']
|
||||
cert.user = student
|
||||
cert.course_id = course_id
|
||||
cert.key = key
|
||||
cert.name = profile.name
|
||||
|
||||
# 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')
|
||||
|
||||
@@ -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']}"
|
||||
|
||||
Reference in New Issue
Block a user