Commit ae8847cd by Joe Blaylock

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.
parent 3ff3973b
...@@ -200,7 +200,7 @@ def cert_info(user, course): ...@@ -200,7 +200,7 @@ def cert_info(user, course):
'survey_url': url, only if show_survey_button is True 'survey_url': url, only if show_survey_button is True
'grade': if status is not 'processing' 'grade': if status is not 'processing'
""" """
if not course.has_ended(): if not course.may_certify():
return {} return {}
return _cert_info(user, course, certificate_status_for_student(user, course.id)) return _cert_info(user, course, certificate_status_for_student(user, course.id))
...@@ -291,6 +291,15 @@ def _cert_info(user, course, cert_status): ...@@ -291,6 +291,15 @@ def _cert_info(user, course, cert_status):
""" """
Implements the logic for cert_info -- split out for testing. 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_status = 'processing'
default_info = {'status': default_status, default_info = {'status': default_status,
...@@ -302,15 +311,6 @@ def _cert_info(user, course, cert_status): ...@@ -302,15 +311,6 @@ def _cert_info(user, course, cert_status):
if cert_status is None: if cert_status is None:
return default_info 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) status = template_state.get(cert_status['status'], default_status)
d = {'status': status, d = {'status': status,
......
...@@ -369,6 +369,9 @@ class CourseFields(object): ...@@ -369,6 +369,9 @@ class CourseFields(object):
) )
enrollment_domain = String(help="External login method associated with user accounts allowed to register in course", enrollment_domain = String(help="External login method associated with user accounts allowed to register in course",
scope=Scope.settings) 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( course_image = String(
help="Filename of the course image", help="Filename of the course image",
scope=Scope.settings, scope=Scope.settings,
...@@ -592,6 +595,12 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -592,6 +595,12 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
return datetime.now(UTC()) > self.end 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): def has_started(self):
return datetime.now(UTC()) > self.start return datetime.now(UTC()) > self.start
......
import unittest import unittest
from datetime import datetime from datetime import datetime, timedelta
from fs.memoryfs import MemoryFS from fs.memoryfs import MemoryFS
...@@ -49,7 +49,7 @@ class DummySystem(ImportSystem): ...@@ -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""" """Get a dummy course"""
system = DummySystem(load_error_modules=True) system = DummySystem(load_error_modules=True)
...@@ -69,17 +69,61 @@ def get_dummy_course(start, announcement=None, is_new=None, advertised_start=Non ...@@ -69,17 +69,61 @@ def get_dummy_course(start, announcement=None, is_new=None, advertised_start=Non
{announcement} {announcement}
{is_new} {is_new}
{advertised_start} {advertised_start}
{end}> {end}
certificates_show_before_end="{certs}">
<chapter url="hi" url_name="ch" display_name="CH"> <chapter url="hi" url_name="ch" display_name="CH">
<html url_name="h" display_name="H">Two houses, ...</html> <html url_name="h" display_name="H">Two houses, ...</html>
</chapter> </chapter>
</course> </course>
'''.format(org=ORG, course=COURSE, start=start, is_new=is_new, '''.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) 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): class IsNewCourseTestCase(unittest.TestCase):
"""Make sure the property is_new works on courses""" """Make sure the property is_new works on courses"""
......
...@@ -15,6 +15,8 @@ from verify_student.models import SoftwareSecurePhotoVerification ...@@ -15,6 +15,8 @@ from verify_student.models import SoftwareSecurePhotoVerification
import json import json
import random import random
import logging import logging
import lxml
from lxml.etree import XMLSyntaxError, ParserError
from xmodule.modulestore import Location from xmodule.modulestore import Location
...@@ -170,6 +172,7 @@ class XQueueCertInterface(object): ...@@ -170,6 +172,7 @@ class XQueueCertInterface(object):
if course is None: if course is None:
course = courses.get_course_by_id(course_id) course = courses.get_course_by_id(course_id)
profile = UserProfile.objects.get(user=student) profile = UserProfile.objects.get(user=student)
profile_name = profile.name
# Needed # Needed
self.request.user = student self.request.user = student
...@@ -201,9 +204,16 @@ class XQueueCertInterface(object): ...@@ -201,9 +204,16 @@ class XQueueCertInterface(object):
cert.user = student cert.user = student
cert.grade = grade['percent'] cert.grade = grade['percent']
cert.course_id = course_id 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 # check to see whether the student is on the
# the embargoed country restricted list # the embargoed country restricted list
...@@ -222,8 +232,8 @@ class XQueueCertInterface(object): ...@@ -222,8 +232,8 @@ class XQueueCertInterface(object):
'username': student.username, 'username': student.username,
'course_id': course_id, 'course_id': course_id,
'course_name': course_name, 'course_name': course_name,
'name': profile.name, 'name': profile_name,
'grade': grade['grade'], 'grade': grade_contents,
'template_pdf': template_pdf, 'template_pdf': template_pdf,
} }
if template_file: if template_file:
...@@ -233,8 +243,8 @@ class XQueueCertInterface(object): ...@@ -233,8 +243,8 @@ class XQueueCertInterface(object):
cert.save() cert.save()
self._send_to_xqueue(contents, key) self._send_to_xqueue(contents, key)
else: else:
new_status = status.notpassing cert_status = status.notpassing
cert.status = new_status cert.status = cert_status
cert.save() cert.save()
return new_status return new_status
......
"""URL handlers related to certificate handling by LMS"""
from dogapi import dog_stats_api
import json
import logging import logging
from certificates.models import GeneratedCertificate
from certificates.models import CertificateStatuses as status from django.contrib.auth.models import User
from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse from django.http import HttpResponse
import json from django.views.decorators.csrf import csrf_exempt
from dogapi import dog_stats_api
from capa.xqueue_interface import XQUEUE_METRIC_NAME 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__) logger = logging.getLogger(__name__)
@csrf_exempt @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): def update_certificate(request):
""" """
Will update GeneratedCertificate for a new certificate or Will update GeneratedCertificate for a new certificate or
...@@ -21,6 +52,7 @@ def update_certificate(request): ...@@ -21,6 +52,7 @@ def update_certificate(request):
This view should only ever be accessed by the xqueue server This view should only ever be accessed by the xqueue server
""" """
status = CertificateStatuses
if request.method == "POST": if request.method == "POST":
xqueue_body = json.loads(request.POST.get('xqueue_body')) xqueue_body = json.loads(request.POST.get('xqueue_body'))
......
...@@ -24,8 +24,11 @@ else: ...@@ -24,8 +24,11 @@ else:
%> %>
<div class="message message-status ${status_css_class} is-shown"> <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> <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'): % elif cert_status['status'] in ('generating', 'ready', 'notpassing', 'restricted'):
<p class="message-copy">${_("Your final grade:")} <p class="message-copy">${_("Your final grade:")}
<span class="grade-value">${"{0:.0f}%".format(float(cert_status['grade'])*100)}</span>. <span class="grade-value">${"{0:.0f}%".format(float(cert_status['grade'])*100)}</span>.
......
...@@ -67,7 +67,7 @@ ...@@ -67,7 +67,7 @@
</h3> </h3>
</hgroup> </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'/> <%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course=course, enrollment=enrollment'/>
% endif % endif
......
...@@ -12,6 +12,7 @@ if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'): ...@@ -12,6 +12,7 @@ if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'):
urlpatterns = ('', # nopep8 urlpatterns = ('', # nopep8
# certificate view # certificate view
url(r'^update_certificate$', 'certificates.views.update_certificate'), 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'^$', 'branding.views.index', name="root"), # Main marketing page, or redirect to courseware
url(r'^dashboard$', 'student.views.dashboard', name="dashboard"), url(r'^dashboard$', 'student.views.dashboard', name="dashboard"),
url(r'^token$', 'student.views.token', name="token"), url(r'^token$', 'student.views.token', name="token"),
......
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