diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 34c30c4503..6e02a2a29b 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -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, diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 3afc44b85b..5eba808cb4 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -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 diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py index 7a60e29e37..e336426c14 100644 --- a/common/lib/xmodule/xmodule/tests/test_course_module.py +++ b/common/lib/xmodule/xmodule/tests/test_course_module.py @@ -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}"> Two houses, ... '''.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 = """ + # + # + # Two houses, ... + # + # + #""".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""" diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 0a1f3ea63f..9588ae7c34 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -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 diff --git a/lms/djangoapps/certificates/views.py b/lms/djangoapps/certificates/views.py index 11f567a58f..ded4b95627 100644 --- a/lms/djangoapps/certificates/views.py +++ b/lms/djangoapps/certificates/views.py @@ -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')) diff --git a/lms/templates/dashboard/_dashboard_certificate_information.html b/lms/templates/dashboard/_dashboard_certificate_information.html index 951091f371..d8b3977574 100644 --- a/lms/templates/dashboard/_dashboard_certificate_information.html +++ b/lms/templates/dashboard/_dashboard_certificate_information.html @@ -24,8 +24,11 @@ else: %>
-% if cert_status['status'] == 'processing': +% if cert_status['status'] == 'processing' and not course.may_certify():

${_("Final course details are being wrapped up at this time. Your final standing will be available shortly.")}

+% elif course.may_certify() and cert_status['status'] == 'processing': + + % elif cert_status['status'] in ('generating', 'ready', 'notpassing', 'restricted'):

${_("Your final grade:")} ${"{0:.0f}%".format(float(cert_status['grade'])*100)}. diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index 0e8e749e69..f415830668 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -67,7 +67,7 @@ - % 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 diff --git a/lms/urls.py b/lms/urls.py index e9a033130a..b489295468 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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"),