Files
edx-platform/lms/djangoapps/certificates/views.py
Zia Fazal 6afaa3cce3 certificates event tracking
some optimisations

refactored code and added created event

added test to make sure generate event is emitted

changes based on feedback on 6/11

added certificate web page and tests

fixed quality violations
2015-06-15 19:53:47 +05:00

632 lines
30 KiB
Python

"""URL handlers related to certificate handling by LMS"""
from microsite_configuration import microsite
from datetime import datetime
from uuid import uuid4
from django.shortcuts import redirect, get_object_or_404
from opaque_keys.edx.locator import CourseLocator
from eventtracking import tracker
import dogstats_wrapper as dog_stats_api
import json
import logging
from django.conf import settings
from django.contrib.auth.models import User
from django.http import HttpResponse, Http404, HttpResponseForbidden
from django.utils.translation import ugettext as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from capa.xqueue_interface import XQUEUE_METRIC_NAME
from certificates.api import (
get_active_web_certificate,
get_certificate_url,
generate_user_certificates,
emit_certificate_event
)
from certificates.models import (
certificate_status_for_student,
CertificateStatuses,
GeneratedCertificate,
ExampleCertificate,
CertificateHtmlViewConfiguration,
CertificateSocialNetworks,
BadgeAssertion
)
from edxmako.shortcuts import render_to_response
from util.views import ensure_valid_course_key
from xmodule.modulestore.django import modulestore
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from student.models import LinkedInAddToProfileConfiguration
from util.json_request import JsonResponse, JsonResponseBadRequest
from util.bad_request_rate_limiter import BadRequestRateLimiter
logger = logging.getLogger(__name__)
class CourseDoesNotExist(Exception):
"""
This exception is raised in the case where None is returned from the modulestore
"""
pass
@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():
username = request.user.username
student = User.objects.get(username=username)
course_key = SlashSeparatedCourseKey.from_deprecated_string(request.POST.get('course_id'))
course = modulestore().get_course(course_key, depth=2)
status = certificate_status_for_student(student, course_key)['status']
if status in [CertificateStatuses.unavailable, CertificateStatuses.notpassing, CertificateStatuses.error]:
log_msg = u'Grading and certification requested for user %s in course %s via /request_certificate call'
logger.info(log_msg, username, course_key)
status = generate_user_certificates(student, course_key, 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):
"""
Will update GeneratedCertificate for a new certificate or
modify an existing certificate entry.
See models.py for a state diagram of certificate states
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'))
xqueue_header = json.loads(request.POST.get('xqueue_header'))
try:
course_key = SlashSeparatedCourseKey.from_deprecated_string(xqueue_body['course_id'])
cert = GeneratedCertificate.objects.get(
user__username=xqueue_body['username'],
course_id=course_key,
key=xqueue_header['lms_key'])
except GeneratedCertificate.DoesNotExist:
logger.critical('Unable to lookup certificate\n'
'xqueue_body: {0}\n'
'xqueue_header: {1}'.format(
xqueue_body, xqueue_header))
return HttpResponse(json.dumps({
'return_code': 1,
'content': 'unable to lookup key'}),
mimetype='application/json')
if 'error' in xqueue_body:
cert.status = status.error
if 'error_reason' in xqueue_body:
# Hopefully we will record a meaningful error
# here if something bad happened during the
# certificate generation process
#
# example:
# (aamorm BerkeleyX/CS169.1x/2012_Fall)
# <class 'simples3.bucket.S3Error'>:
# HTTP error (reason=error(32, 'Broken pipe'), filename=None) :
# certificate_agent.py:175
cert.error_reason = xqueue_body['error_reason']
else:
if cert.status in [status.generating, status.regenerating]:
cert.download_uuid = xqueue_body['download_uuid']
cert.verify_uuid = xqueue_body['verify_uuid']
cert.download_url = xqueue_body['url']
cert.status = status.downloadable
elif cert.status in [status.deleting]:
cert.status = status.deleted
else:
logger.critical('Invalid state for cert update: {0}'.format(
cert.status))
return HttpResponse(
json.dumps({
'return_code': 1,
'content': 'invalid cert status'
}),
mimetype='application/json'
)
dog_stats_api.increment(XQUEUE_METRIC_NAME, tags=[
u'action:update_certificate',
u'course_id:{}'.format(cert.course_id)
])
cert.save()
return HttpResponse(json.dumps({'return_code': 0}),
mimetype='application/json')
@csrf_exempt
@require_POST
def update_example_certificate(request):
"""Callback from the XQueue that updates example certificates.
Example certificates are used to verify that certificate
generation is configured correctly for a course.
Unlike other certificates, example certificates
are not associated with a particular user or displayed
to students.
For this reason, we need a different end-point to update
the status of generated example certificates.
Arguments:
request (HttpRequest)
Returns:
HttpResponse (200): Status was updated successfully.
HttpResponse (400): Invalid parameters.
HttpResponse (403): Rate limit exceeded for bad requests.
HttpResponse (404): Invalid certificate identifier or access key.
"""
logger.info(u"Received response for example certificate from XQueue.")
rate_limiter = BadRequestRateLimiter()
# Check the parameters and rate limits
# If these are invalid, return an error response.
if rate_limiter.is_rate_limit_exceeded(request):
logger.info(u"Bad request rate limit exceeded for update example certificate end-point.")
return HttpResponseForbidden("Rate limit exceeded")
if 'xqueue_body' not in request.POST:
logger.info(u"Missing parameter 'xqueue_body' for update example certificate end-point")
rate_limiter.tick_bad_request_counter(request)
return JsonResponseBadRequest("Parameter 'xqueue_body' is required.")
if 'xqueue_header' not in request.POST:
logger.info(u"Missing parameter 'xqueue_header' for update example certificate end-point")
rate_limiter.tick_bad_request_counter(request)
return JsonResponseBadRequest("Parameter 'xqueue_header' is required.")
try:
xqueue_body = json.loads(request.POST['xqueue_body'])
xqueue_header = json.loads(request.POST['xqueue_header'])
except (ValueError, TypeError):
logger.info(u"Could not decode params to example certificate end-point as JSON.")
rate_limiter.tick_bad_request_counter(request)
return JsonResponseBadRequest("Parameters must be JSON-serialized.")
# Attempt to retrieve the example certificate record
# so we can update the status.
try:
uuid = xqueue_body.get('username')
access_key = xqueue_header.get('lms_key')
cert = ExampleCertificate.objects.get(uuid=uuid, access_key=access_key)
except ExampleCertificate.DoesNotExist:
# If we are unable to retrieve the record, it means the uuid or access key
# were not valid. This most likely means that the request is NOT coming
# from the XQueue. Return a 404 and increase the bad request counter
# to protect against a DDOS attack.
logger.info(u"Could not find example certificate with uuid '%s' and access key '%s'", uuid, access_key)
rate_limiter.tick_bad_request_counter(request)
raise Http404
if 'error' in xqueue_body:
# If an error occurs, save the error message so we can fix the issue.
error_reason = xqueue_body.get('error_reason')
cert.update_status(ExampleCertificate.STATUS_ERROR, error_reason=error_reason)
logger.warning(
(
u"Error occurred during example certificate generation for uuid '%s'. "
u"The error response was '%s'."
), uuid, error_reason
)
else:
# If the certificate generated successfully, save the download URL
# so we can display the example certificate.
download_url = xqueue_body.get('url')
if download_url is None:
rate_limiter.tick_bad_request_counter(request)
logger.warning(u"No download URL provided for example certificate with uuid '%s'.", uuid)
return JsonResponseBadRequest(
"Parameter 'download_url' is required for successfully generated certificates."
)
else:
cert.update_status(ExampleCertificate.STATUS_SUCCESS, download_url=download_url)
logger.info("Successfully updated example certificate with uuid '%s'.", uuid)
# Let the XQueue know that we handled the response
return JsonResponse({'return_code': 0})
def get_certificate_description(mode, certificate_type, platform_name):
"""
:return certificate_type_description on the basis of current mode
"""
certificate_type_description = None
if mode == 'honor':
# Translators: This text describes the 'Honor' course certificate type.
certificate_type_description = _("An {cert_type} Certificate signifies that an {platform_name} "
"learner has agreed to abide by {platform_name}'s honor code and "
"completed all of the required tasks for this course under its "
"guidelines.").format(cert_type=certificate_type,
platform_name=platform_name)
elif mode == 'verified':
# Translators: This text describes the 'ID Verified' course certificate type, which is a higher level of
# verification offered by edX. This type of verification is useful for professional education/certifications
certificate_type_description = _("An {cert_type} Certificate signifies that an {platform_name} "
"learner has agreed to abide by {platform_name}'s honor code and "
"completed all of the required tasks for this course under its "
"guidelines, as well as having their photo ID checked to verify "
"their identity.").format(cert_type=certificate_type,
platform_name=platform_name)
elif mode == 'xseries':
# Translators: This text describes the 'XSeries' course certificate type. An XSeries is a collection of
# courses related to each other in a meaningful way, such as a specific topic or theme, or even an organization
certificate_type_description = _("An {cert_type} Certificate demonstrates a high level of "
"achievement in a program of study, and includes verification of "
"the student's identity.").format(cert_type=certificate_type)
return certificate_type_description
# pylint: disable=bad-continuation
def _update_certificate_context(context, course, user, user_certificate):
"""
Build up the certificate web view context using the provided values
(Helper method to keep the view clean)
"""
# Populate dynamic output values using the course/certificate data loaded above
user_fullname = user.profile.name
platform_name = microsite.get_value("platform_name", settings.PLATFORM_NAME)
certificate_type = context.get('certificate_type')
context['username'] = user.username
context['course_mode'] = user_certificate.mode
context['accomplishment_user_id'] = user.id
context['accomplishment_copy_name'] = user_fullname
context['accomplishment_copy_username'] = user.username
context['accomplishment_copy_course_org'] = course.org
context['accomplishment_copy_course_name'] = course.display_name
try:
badge = BadgeAssertion.objects.get(user=user, course_id=course.location.course_key)
except BadgeAssertion.DoesNotExist:
badge = None
context['badge'] = badge
# Override the defaults with any mode-specific static values
context['certificate_id_number'] = user_certificate.verify_uuid
context['certificate_verify_url'] = "{prefix}{uuid}{suffix}".format(
prefix=context.get('certificate_verify_url_prefix'),
uuid=user_certificate.verify_uuid,
suffix=context.get('certificate_verify_url_suffix')
)
# Translators: The format of the date includes the full name of the month
context['certificate_date_issued'] = _('{month} {day}, {year}').format(
month=user_certificate.modified_date.strftime("%B"),
day=user_certificate.modified_date.day,
year=user_certificate.modified_date.year
)
accd_course_org_html = '<span class="detail--xuniversity">{partner_name}</span>'.format(partner_name=course.org)
accd_platform_name_html = '<span class="detail--company">{platform_name}</span>'.format(platform_name=platform_name)
# Translators: This line appears on the certificate after the name of a course, and provides more
# information about the organizations providing the course material to platform users
context['accomplishment_copy_course_description'] = _('a course of study offered by {partner_name}, '
'through {platform_name}.').format(
partner_name=accd_course_org_html,
platform_name=accd_platform_name_html
)
# Translators: Accomplishments describe the awards/certifications obtained by students on this platform
context['accomplishment_copy_about'] = _('About {platform_name} Accomplishments').format(
platform_name=platform_name
)
context['accomplishment_more_title'] = _("More Information About {user_name}'s Certificate:").format(
user_name=user_fullname
)
# Translators: This line appears on the page just before the generation date for the certificate
context['certificate_date_issued_title'] = _("Issued On:")
# Translators: The Certificate ID Number is an alphanumeric value unique to each individual certificate
context['certificate_id_number_title'] = _('Certificate ID Number')
context['certificate_info_title'] = _('About {platform_name} Certificates').format(
platform_name=platform_name
)
# Translators: This text describes the purpose (and therefore, value) of a course certificate
# 'verifying your identity' refers to the process for establishing the authenticity of the student
context['certificate_info_description'] = _("{platform_name} acknowledges achievements through certificates, which "
"are awarded for various activities {platform_name} students complete "
"under the <a href='{tos_url}'>{platform_name} Honor Code</a>. Some "
"certificates require completing additional steps, such as "
"<a href='{verified_cert_url}'> verifying your identity</a>.").format(
platform_name=platform_name,
tos_url=context.get('company_tos_url'),
verified_cert_url=context.get('company_verified_certificate_url')
)
context['certificate_verify_title'] = _("How {platform_name} Validates Student Certificates").format(
platform_name=platform_name
)
# Translators: This text describes the validation mechanism for a certificate file (known as GPG security)
context['certificate_verify_description'] = _('Certificates issued by {platform_name} are signed by a gpg key so '
'that they can be validated independently by anyone with the '
'{platform_name} public key. For independent verification, '
'{platform_name} uses what is called a '
'"detached signature"&quot;".').format(platform_name=platform_name)
context['certificate_verify_urltext'] = _("Validate this certificate for yourself")
# Translators: This text describes (at a high level) the mission and charter the edX platform and organization
context['company_about_description'] = _("{platform_name} offers interactive online classes and MOOCs from the "
"world's best universities, including MIT, Harvard, Berkeley, University "
"of Texas, and many others. {platform_name} is a non-profit online "
"initiative created by founding partners Harvard and MIT.").format(
platform_name=platform_name
)
context['company_about_title'] = _("About {platform_name}").format(platform_name=platform_name)
context['company_about_urltext'] = _("Learn more about {platform_name}").format(platform_name=platform_name)
context['company_courselist_urltext'] = _("Learn with {platform_name}").format(platform_name=platform_name)
context['company_careers_urltext'] = _("Work at {platform_name}").format(platform_name=platform_name)
context['company_contact_urltext'] = _("Contact {platform_name}").format(platform_name=platform_name)
# Translators: This text appears near the top of the certficate and describes the guarantee provided by edX
context['document_banner'] = _("{platform_name} acknowledges the following student accomplishment").format(
platform_name=platform_name
)
# Translators: This text represents the verification of the certificate
context['document_meta_description'] = _('This is a valid {platform_name} certificate for {user_name}, '
'who participated in {partner_name} {course_number}').format(
platform_name=platform_name,
user_name=user_fullname,
partner_name=course.org,
course_number=course.number
)
# Translators: This text is bound to the HTML 'title' element of the page and appears in the browser title bar
context['document_title'] = _("{partner_name} {course_number} Certificate | {platform_name}").format(
partner_name=course.org,
course_number=course.number,
platform_name=platform_name
)
# Translators: This text fragment appears after the student's name (displayed in a large font) on the certificate
# screen. The text describes the accomplishment represented by the certificate information displayed to the user
context['accomplishment_copy_description_full'] = _("successfully completed, received a passing grade, and was "
"awarded a {platform_name} {certificate_type} "
"Certificate of Completion in ").format(
platform_name=platform_name,
certificate_type=context.get("certificate_type")
)
certificate_type_description = get_certificate_description(user_certificate.mode, certificate_type, platform_name)
if certificate_type_description:
context['certificate_type_description'] = certificate_type_description
# If enabled, show the LinkedIn "add to profile" button
# Clicking this button sends the user to LinkedIn where they
# can add the certificate information to their profile.
linkedin_config = LinkedInAddToProfileConfiguration.current()
if linkedin_config.enabled:
context['linked_in_url'] = linkedin_config.add_to_profile_url(
course.id,
course.display_name,
user_certificate.mode,
get_certificate_url(
user_id=user.id,
course_id=course.id.to_deprecated_string()
)
)
# Translators: This line is displayed to a user who has completed a course and achieved a certification
context['accomplishment_banner_opening'] = _("{fullname}, you've earned a certificate!").format(
fullname=user_fullname
)
# Translators: This line congratulates the user and instructs them to share their accomplishment on social networks
context['accomplishment_banner_congrats'] = _("Congratulations! This page summarizes all of the details of what "
"you've accomplished. Show it off to family, friends, and colleagues "
"in your social and professional networks.")
# Translators: This line leads the reader to understand more about the certificate that a student has been awarded
context['accomplishment_copy_more_about'] = _("More about {fullname}'s accomplishment").format(
fullname=user_fullname
)
def render_html_view(request, user_id, course_id):
"""
This public view generates an HTML representation of the specified student's certificate
If a certificate is not available, we display a "Sorry!" screen instead
"""
# Create the initial view context, bootstrapping with Django settings and passed-in values
context = {}
context['platform_name'] = microsite.get_value("platform_name", settings.PLATFORM_NAME)
context['course_id'] = course_id
# Update the view context with the default ConfigurationModel settings
configuration = CertificateHtmlViewConfiguration.get_config()
# if we are in a microsite, then let's first see if there is an override
# section in our config
config_key = microsite.get_value('microsite_config_key', 'default')
# if there is no special microsite override, then let's use default
if config_key not in configuration:
config_key = 'default'
context.update(configuration.get(config_key, {}))
# Translators: 'All rights reserved' is a legal term used in copyrighting to protect published content
reserved = _("All rights reserved")
context['copyright_text'] = '&copy; {year} {platform_name}. {reserved}.'.format(
year=settings.COPYRIGHT_YEAR,
platform_name=context.get('platform_name'),
reserved=reserved
)
# Translators: This text is bound to the HTML 'title' element of the page and appears
# in the browser title bar when a requested certificate is not found or recognized
context['document_title'] = _("Invalid Certificate")
# Translators: The &amp; characters represent an ampersand character and can be ignored
context['company_tos_urltext'] = _("Terms of Service &amp; Honor Code")
# Translators: A 'Privacy Policy' is a legal document/statement describing a website's use of personal information
context['company_privacy_urltext'] = _("Privacy Policy")
# Translators: This line appears as a byline to a header image and describes the purpose of the page
context['logo_subtitle'] = _("Certificate Validation")
context['logo_alt'] = context.get('platform_name')
invalid_template_path = 'certificates/invalid.html'
# Kick the user back to the "Invalid" screen if the feature is disabled
if not settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False):
return render_to_response(invalid_template_path, context)
# Load the core building blocks for the view context
try:
course_key = CourseKey.from_string(course_id)
user = User.objects.get(id=user_id)
course = modulestore().get_course(course_key)
if not course:
raise CourseDoesNotExist
# Attempt to load the user's generated certificate data
user_certificate = GeneratedCertificate.objects.get(
user=user,
course_id=course_key
)
# If there's no generated certificate data for this user, we need to see if we're in 'preview' mode...
# If we are, we'll need to create a mock version of the user_certificate container for previewing
except GeneratedCertificate.DoesNotExist:
if request.GET.get('preview', None):
user_certificate = GeneratedCertificate(
mode=request.GET.get('preview'),
verify_uuid=unicode(uuid4().hex),
modified_date=datetime.now().date()
)
else:
return render_to_response(invalid_template_path, context)
# For any other expected exceptions, kick the user back to the "Invalid" screen
except (InvalidKeyError, CourseDoesNotExist, User.DoesNotExist):
return render_to_response(invalid_template_path, context)
# Badge Request Event Tracking Logic
if 'evidence_visit' in request.GET:
try:
badge = BadgeAssertion.objects.get(user=user, course_id=course_key)
tracker.emit(
'edx.badge.assertion.evidence_visited',
{
'user_id': user.id,
'course_id': unicode(course_key),
'enrollment_mode': badge.mode,
'assertion_id': badge.id,
'assertion_image_url': badge.data['image'],
'assertion_json_url': badge.data['json']['id'],
'issuer': badge.data['issuer'],
}
)
except BadgeAssertion.DoesNotExist:
logger.warn(
"Could not find badge for %s on course %s.",
user.id,
course_key,
)
# Okay, now we have all of the pieces, time to put everything together
# Get the active certificate configuration for this course
# If we do not have an active certificate, we'll need to send the user to the "Invalid" screen
# Passing in the 'preview' parameter, if specified, will return a configuration, if defined
active_configuration = get_active_web_certificate(course, request.GET.get('preview'))
if active_configuration is None:
return render_to_response(invalid_template_path, context)
else:
context['certificate_data'] = active_configuration
# Append/Override the existing view context values with any mode-specific ConfigurationModel values
context.update(configuration.get(user_certificate.mode, {}))
# Append/Override the existing view context values with request-time values
_update_certificate_context(context, course, user, user_certificate)
# Microsites will need to be able to override any hard coded
# content that was put into the context in the
# _update_certificate_context() call above. For example the
# 'company_about_description' talks about edX, which we most likely
# do not want to keep in a microsite
#
# So we need to re-apply any configuration/content that
# we are sourceing from the database. This is somewhat duplicative of
# the code at the beginning of this method, but we
# need the configuration at the top as some error code paths
# require that to be set up early on in the pipeline
#
microsite_config_key = microsite.get_value('microsite_config_key')
if microsite_config_key:
context.update(configuration.get(microsite_config_key, {}))
# track certificate evidence_visited event for analytics when certificate_user and accessing_user are different
if request.user and request.user.id != user.id:
emit_certificate_event('evidence_visited', user, course_id, course, {
'certificate_id': user_certificate.verify_uuid,
'enrollment_mode': user_certificate.mode,
'social_network': CertificateSocialNetworks.linkedin
})
# Append/Override the existing view context values with any course-specific static values from Advanced Settings
context.update(course.cert_html_view_overrides)
# FINALLY, generate and send the output the client
return render_to_response("certificates/valid.html", context)
@ensure_valid_course_key
def track_share_redirect(request__unused, course_id, network, student_username):
"""
Tracks when a user downloads a badge for sharing.
"""
course_id = CourseLocator.from_string(course_id)
assertion = get_object_or_404(BadgeAssertion, user__username=student_username, course_id=course_id)
tracker.emit(
'edx.badge.assertion.shared', {
'course_id': unicode(course_id),
'social_network': network,
'assertion_id': assertion.id,
'assertion_json_url': assertion.data['json']['id'],
'assertion_image_url': assertion.image_url,
'user_id': assertion.user.id,
'enrollment_mode': assertion.mode,
'issuer': assertion.data['issuer'],
}
)
return redirect(assertion.image_url)