Certificates: URL endpoint for cert gen
* API endpoint for certificate generation, an authenticated post with course
id requests that grading be carried out and a cert generated for
request.user in that course, using the usual grading and certificate
machinery (ie, it does not imply whitelisting, though whitelists and
blacklists will be respected)
- Logs each request as it comes in
- Calls xq.add_cert() and consequently, does grading synchronously on
this app host and then queues request for certificate agent.
- example usage:
```
curl --data "student_id=9999&course_id=Stanford/2013/Some_Class" http://127.0.0.1:8000/request_certificate
```
* Studio advanced setting added, "certificates_show_before_end", which
determines whether a course should permit certificates to be downloadable
by students before the coures's end date has passed.
- Modifications to dashboard view and templates to allow display of
certificate download links before course has ended.
(XXX: may declare failing students as failing before the course has ended.)
- To test, turn the setting on in a course which hasn't ended yet, and
force certificate generation for a student, then check their
dashboard.
This commit is contained in:
@@ -200,7 +200,7 @@ def cert_info(user, course):
|
||||
'survey_url': url, only if show_survey_button is True
|
||||
'grade': if status is not 'processing'
|
||||
"""
|
||||
if not course.has_ended():
|
||||
if not course.may_certify():
|
||||
return {}
|
||||
|
||||
return _cert_info(user, course, certificate_status_for_student(user, course.id))
|
||||
@@ -291,6 +291,15 @@ def _cert_info(user, course, cert_status):
|
||||
"""
|
||||
Implements the logic for cert_info -- split out for testing.
|
||||
"""
|
||||
# simplify the status for the template using this lookup table
|
||||
template_state = {
|
||||
CertificateStatuses.generating: 'generating',
|
||||
CertificateStatuses.regenerating: 'generating',
|
||||
CertificateStatuses.downloadable: 'ready',
|
||||
CertificateStatuses.notpassing: 'notpassing',
|
||||
CertificateStatuses.restricted: 'restricted',
|
||||
}
|
||||
|
||||
default_status = 'processing'
|
||||
|
||||
default_info = {'status': default_status,
|
||||
@@ -302,15 +311,6 @@ def _cert_info(user, course, cert_status):
|
||||
if cert_status is None:
|
||||
return default_info
|
||||
|
||||
# simplify the status for the template using this lookup table
|
||||
template_state = {
|
||||
CertificateStatuses.generating: 'generating',
|
||||
CertificateStatuses.regenerating: 'generating',
|
||||
CertificateStatuses.downloadable: 'ready',
|
||||
CertificateStatuses.notpassing: 'notpassing',
|
||||
CertificateStatuses.restricted: 'restricted',
|
||||
}
|
||||
|
||||
status = template_state.get(cert_status['status'], default_status)
|
||||
|
||||
d = {'status': status,
|
||||
|
||||
@@ -369,6 +369,9 @@ class CourseFields(object):
|
||||
)
|
||||
enrollment_domain = String(help="External login method associated with user accounts allowed to register in course",
|
||||
scope=Scope.settings)
|
||||
certificates_show_before_end = Boolean(help="True if students may download certificates before course end",
|
||||
scope=Scope.settings,
|
||||
default=False)
|
||||
course_image = String(
|
||||
help="Filename of the course image",
|
||||
scope=Scope.settings,
|
||||
@@ -592,6 +595,12 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
|
||||
return datetime.now(UTC()) > self.end
|
||||
|
||||
def may_certify(self):
|
||||
"""
|
||||
Return True if it is acceptable to show the student a certificate download link
|
||||
"""
|
||||
return self.certificates_show_before_end or self.has_ended()
|
||||
|
||||
def has_started(self):
|
||||
return datetime.now(UTC()) > self.start
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fs.memoryfs import MemoryFS
|
||||
|
||||
@@ -49,7 +49,7 @@ class DummySystem(ImportSystem):
|
||||
)
|
||||
|
||||
|
||||
def get_dummy_course(start, announcement=None, is_new=None, advertised_start=None, end=None):
|
||||
def get_dummy_course(start, announcement=None, is_new=None, advertised_start=None, end=None, certs=False):
|
||||
"""Get a dummy course"""
|
||||
|
||||
system = DummySystem(load_error_modules=True)
|
||||
@@ -69,17 +69,61 @@ def get_dummy_course(start, announcement=None, is_new=None, advertised_start=Non
|
||||
{announcement}
|
||||
{is_new}
|
||||
{advertised_start}
|
||||
{end}>
|
||||
{end}
|
||||
certificates_show_before_end="{certs}">
|
||||
<chapter url="hi" url_name="ch" display_name="CH">
|
||||
<html url_name="h" display_name="H">Two houses, ...</html>
|
||||
</chapter>
|
||||
</course>
|
||||
'''.format(org=ORG, course=COURSE, start=start, is_new=is_new,
|
||||
announcement=announcement, advertised_start=advertised_start, end=end)
|
||||
announcement=announcement, advertised_start=advertised_start, end=end,
|
||||
certs=certs)
|
||||
|
||||
return system.process_xml(start_xml)
|
||||
|
||||
|
||||
class HasEndedMayCertifyTestCase(unittest.TestCase):
|
||||
"""Double check the semantics around when to finalize courses."""
|
||||
|
||||
def setUp(self):
|
||||
system = DummySystem(load_error_modules=True)
|
||||
#sample_xml = """
|
||||
# <course org="{org}" course="{course}" display_organization="{org}_display" display_coursenumber="{course}_display"
|
||||
# graceperiod="1 day" url_name="test"
|
||||
# start="2012-01-01T12:00"
|
||||
# {end}
|
||||
# certificates_show_before_end={cert}>
|
||||
# <chapter url="hi" url_name="ch" display_name="CH">
|
||||
# <html url_name="h" display_name="H">Two houses, ...</html>
|
||||
# </chapter>
|
||||
# </course>
|
||||
#""".format(org=ORG, course=COURSE)
|
||||
past_end = (datetime.now() - timedelta(days=12)).strftime("%Y-%m-%dT%H:%M:00")
|
||||
future_end = (datetime.now() + timedelta(days=12)).strftime("%Y-%m-%dT%H:%M:00")
|
||||
self.past_show_certs = get_dummy_course("2012-01-01T12:00", end=past_end, certs=True)
|
||||
self.past_noshow_certs = get_dummy_course("2012-01-01T12:00", end=past_end, certs=False)
|
||||
self.future_show_certs = get_dummy_course("2012-01-01T12:00", end=future_end, certs=True)
|
||||
self.future_noshow_certs = get_dummy_course("2012-01-01T12:00", end=future_end, certs=False)
|
||||
#self.past_show_certs = system.process_xml(sample_xml.format(end=past_end, cert=True))
|
||||
#self.past_noshow_certs = system.process_xml(sample_xml.format(end=past_end, cert=False))
|
||||
#self.future_show_certs = system.process_xml(sample_xml.format(end=future_end, cert=True))
|
||||
#self.future_noshow_certs = system.process_xml(sample_xml.format(end=future_end, cert=False))
|
||||
|
||||
def test_has_ended(self):
|
||||
"""Check that has_ended correctly tells us when a course is over."""
|
||||
self.assertTrue(self.past_show_certs.has_ended())
|
||||
self.assertTrue(self.past_noshow_certs.has_ended())
|
||||
self.assertFalse(self.future_show_certs.has_ended())
|
||||
self.assertFalse(self.future_noshow_certs.has_ended())
|
||||
|
||||
def test_may_certify(self):
|
||||
"""Check that may_certify correctly tells us when a course may wrap."""
|
||||
self.assertTrue(self.past_show_certs.may_certify())
|
||||
self.assertTrue(self.past_noshow_certs.may_certify())
|
||||
self.assertTrue(self.future_show_certs.may_certify())
|
||||
self.assertFalse(self.future_noshow_certs.may_certify())
|
||||
|
||||
|
||||
class IsNewCourseTestCase(unittest.TestCase):
|
||||
"""Make sure the property is_new works on courses"""
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ from verify_student.models import SoftwareSecurePhotoVerification
|
||||
import json
|
||||
import random
|
||||
import logging
|
||||
import lxml
|
||||
from lxml.etree import XMLSyntaxError, ParserError
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
|
||||
@@ -170,6 +172,7 @@ class XQueueCertInterface(object):
|
||||
if course is None:
|
||||
course = courses.get_course_by_id(course_id)
|
||||
profile = UserProfile.objects.get(user=student)
|
||||
profile_name = profile.name
|
||||
|
||||
# Needed
|
||||
self.request.user = student
|
||||
@@ -201,9 +204,16 @@ class XQueueCertInterface(object):
|
||||
cert.user = student
|
||||
cert.grade = grade['percent']
|
||||
cert.course_id = course_id
|
||||
cert.name = profile.name
|
||||
cert.name = profile_name
|
||||
# Strip HTML from grade range label
|
||||
grade_contents = grade.get('grade', None)
|
||||
try:
|
||||
grade_contents = lxml.html.fromstring(grade_contents).text_content()
|
||||
except (TypeError, XMLSyntaxError, ParserError) as e:
|
||||
# Despite blowing up the xml parser, bad values here are fine
|
||||
grade_contents = None
|
||||
|
||||
if is_whitelisted or grade['grade'] is not None:
|
||||
if is_whitelisted or grade_contents is not None:
|
||||
|
||||
# check to see whether the student is on the
|
||||
# the embargoed country restricted list
|
||||
@@ -222,8 +232,8 @@ class XQueueCertInterface(object):
|
||||
'username': student.username,
|
||||
'course_id': course_id,
|
||||
'course_name': course_name,
|
||||
'name': profile.name,
|
||||
'grade': grade['grade'],
|
||||
'name': profile_name,
|
||||
'grade': grade_contents,
|
||||
'template_pdf': template_pdf,
|
||||
}
|
||||
if template_file:
|
||||
@@ -233,8 +243,8 @@ class XQueueCertInterface(object):
|
||||
cert.save()
|
||||
self._send_to_xqueue(contents, key)
|
||||
else:
|
||||
new_status = status.notpassing
|
||||
cert.status = new_status
|
||||
cert_status = status.notpassing
|
||||
cert.status = cert_status
|
||||
cert.save()
|
||||
|
||||
return new_status
|
||||
|
||||
@@ -1,15 +1,46 @@
|
||||
import logging
|
||||
from certificates.models import GeneratedCertificate
|
||||
from certificates.models import CertificateStatuses as status
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.http import HttpResponse
|
||||
import json
|
||||
"""URL handlers related to certificate handling by LMS"""
|
||||
from dogapi import dog_stats_api
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import HttpResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from capa.xqueue_interface import XQUEUE_METRIC_NAME
|
||||
from certificates.models import certificate_status_for_student, CertificateStatuses, GeneratedCertificate
|
||||
from certificates.queue import XQueueCertInterface
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def request_certificate(request):
|
||||
"""Request the on-demand creation of a certificate for some user, course.
|
||||
|
||||
A request doesn't imply a guarantee that such a creation will take place.
|
||||
We intentionally use the same machinery as is used for doing certification
|
||||
at the end of a course run, so that we can be sure users get graded and
|
||||
then if and only if they pass, do they get a certificate issued.
|
||||
"""
|
||||
if request.method == "POST":
|
||||
if request.user.is_authenticated():
|
||||
xqci = XQueueCertInterface()
|
||||
username = request.user.username
|
||||
student = User.objects.get(username=username)
|
||||
course_id = request.POST.get('course_id')
|
||||
course = modulestore().get_instance(course_id, CourseDescriptor.id_to_location(course_id), depth=2)
|
||||
|
||||
status = certificate_status_for_student(student, course_id)['status']
|
||||
if status in [CertificateStatuses.unavailable, CertificateStatuses.notpassing, CertificateStatuses.error]:
|
||||
logger.info('Grading and certification requested for user {} in course {} via /request_certificate call'.format(username, course_id))
|
||||
status = xqci.add_cert(student, course_id, course=course)
|
||||
return HttpResponse(json.dumps({'add_status': status}), mimetype='application/json')
|
||||
return HttpResponse(json.dumps({'add_status': 'ERRORANONYMOUSUSER'}), mimetype='application/json')
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def update_certificate(request):
|
||||
"""
|
||||
@@ -21,6 +52,7 @@ def update_certificate(request):
|
||||
This view should only ever be accessed by the xqueue server
|
||||
"""
|
||||
|
||||
status = CertificateStatuses
|
||||
if request.method == "POST":
|
||||
|
||||
xqueue_body = json.loads(request.POST.get('xqueue_body'))
|
||||
|
||||
@@ -24,8 +24,11 @@ else:
|
||||
%>
|
||||
<div class="message message-status ${status_css_class} is-shown">
|
||||
|
||||
% if cert_status['status'] == 'processing':
|
||||
% if cert_status['status'] == 'processing' and not course.may_certify():
|
||||
<p class="message-copy">${_("Final course details are being wrapped up at this time. Your final standing will be available shortly.")}</p>
|
||||
% elif course.may_certify() and cert_status['status'] == 'processing':
|
||||
<!-- Certification is allowed but no cert requested, or cert unearned -->
|
||||
<!-- <p class="message-copy">${_("Your final standing is unrequested or unavailable at this time.")}</p> -->
|
||||
% 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>.
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
</h3>
|
||||
</hgroup>
|
||||
|
||||
% if course.has_ended() and cert_status and not enrollment.mode == 'audit':
|
||||
% if course.may_certify() and cert_status and not enrollment.mode == 'audit':
|
||||
<%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course=course, enrollment=enrollment'/>
|
||||
% endif
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'):
|
||||
urlpatterns = ('', # nopep8
|
||||
# certificate view
|
||||
url(r'^update_certificate$', 'certificates.views.update_certificate'),
|
||||
url(r'^request_certificate$', 'certificates.views.request_certificate'),
|
||||
url(r'^$', 'branding.views.index', name="root"), # Main marketing page, or redirect to courseware
|
||||
url(r'^dashboard$', 'student.views.dashboard', name="dashboard"),
|
||||
url(r'^token$', 'student.views.token', name="token"),
|
||||
|
||||
Reference in New Issue
Block a user