Commit c3b78ea9 by Matt Drayer

Merge pull request #8448 from edx/ziafazal/SOL-886

certificates event tracking SOL-886
parents c28003b5 6afaa3cc
"""
Tools for creating certificates config fixture data.
"""
import json
from . import STUDIO_BASE_URL
from .base import StudioApiFixture
class CertificateConfigFixtureError(Exception):
"""
Error occurred while installing certificate config fixture.
"""
pass
class CertificateConfigFixture(StudioApiFixture):
"""
Fixture to create certificates configuration for a course
"""
certificates = []
def __init__(self, course_id, certificates_data):
self.course_id = course_id
self.certificates = certificates_data
super(CertificateConfigFixture, self).__init__()
def install(self):
"""
Push the certificates config data to certificate endpoint.
"""
response = self.session.post(
'{}/certificates/{}'.format(STUDIO_BASE_URL, self.course_id),
data=json.dumps(self.certificates),
headers=self.headers
)
if not response.ok:
raise CertificateConfigFixtureError(
"Could not create certificate {0}. Status was {1}".format(
json.dumps(self.certificates), response.status_code
)
)
return self
# -*- coding: utf-8 -*-
"""
Module for Certificates pages.
"""
from bok_choy.page_object import PageObject
from . import BASE_URL
class CertificatePage(PageObject):
"""
Certificate web view page.
"""
url_path = "certificates"
def __init__(self, browser, user_id, course_id):
"""Initialize the page.
Arguments:
browser (Browser): The browser instance.
user_id: id of the user whom certificate is awarded
course_id: course key of the course where certificate is awarded
"""
super(CertificatePage, self).__init__(browser)
self.user_id = user_id
self.course_id = course_id
def is_browser_on_page(self):
""" Checks if certificate web view page is being viewed """
return self.q(css='section.about-accomplishments').present
@property
def url(self):
"""
Construct a URL to the page
"""
return BASE_URL + "/" + self.url_path + "/user/" + self.user_id + "/course/" + self.course_id
@property
def accomplishment_banner(self):
"""
returns accomplishment banner.
"""
return self.q(css='section.banner-user')
@property
def add_to_linkedin_profile_button(self):
"""
returns add to LinkedIn profile button
"""
return self.q(css='a.action-linkedin-profile')
"""
Acceptance tests for the certificate web view feature.
"""
from ..helpers import UniqueCourseTest, EventsTestMixin
from nose.plugins.attrib import attr
from ...fixtures.course import CourseFixture
from ...fixtures.certificates import CertificateConfigFixture
from ...pages.lms.auto_auth import AutoAuthPage
from ...pages.lms.certificate_page import CertificatePage
@attr('shard_5')
class CertificateWebViewTest(EventsTestMixin, UniqueCourseTest):
"""
Tests for verifying certificate web view features
"""
def setUp(self):
super(CertificateWebViewTest, self).setUp()
# set same course number as we have in fixture json
self.course_info['number'] = "335535897951379478207964576572017930000"
test_certificate_config = {
'id': 1,
'name': 'Certificate name',
'description': 'Certificate description',
'course_title': 'Course title override',
'signatories': [],
'version': 1,
'is_active': True
}
course_settings = {'certificates': test_certificate_config}
self.course_fixture = CourseFixture(
self.course_info["org"],
self.course_info["number"],
self.course_info["run"],
self.course_info["display_name"],
settings=course_settings
)
self.course_fixture.install()
self.user_id = "99" # we have createad a user with this id in fixture
self.cert_fixture = CertificateConfigFixture(self.course_id, test_certificate_config)
# Load certificate web view page for use by the tests
self.certificate_page = CertificatePage(self.browser, self.user_id, self.course_id)
def log_in_as_unique_user(self):
"""
Log in as a valid lms user.
"""
AutoAuthPage(
self.browser,
username="testcert",
email="cert@example.com",
password="testuser",
course_id=self.course_id
).visit()
def test_page_has_accomplishments_banner(self):
"""
Scenario: User accomplishment banner should be present if logged in user is the one who is awarded
the certificate
Given there is a course with certificate configuration
And I have passed the course and certificate is generated
When I view the certificate web view page
Then I should see the accomplishment banner
And When I click on `Add to Profile` button `edx.certificate.shared` event should be emitted
"""
self.cert_fixture.install()
self.log_in_as_unique_user()
self.certificate_page.visit()
self.assertTrue(self.certificate_page.accomplishment_banner.visible)
self.assertTrue(self.certificate_page.add_to_linkedin_profile_button.visible)
self.certificate_page.add_to_linkedin_profile_button.click()
actual_events = self.wait_for_events(
event_filter={'event_type': 'edx.certificate.shared'},
number_of_matches=1
)
expected_events = [
{
'event': {
'user_id': self.user_id,
'course_id': self.course_id
}
}
]
self.assert_events_match(expected_events, actual_events)
[
{
"pk": 99,
"model": "auth.user",
"fields": {
"date_joined": "2015-06-12 11:02:13",
"username": "testcert",
"first_name": "john",
"last_name": "doe",
"email":"cert@example.com",
"password": "testuser",
"is_staff": false,
"is_active": true
}
},
{
"pk": 99,
"model": "student.userprofile",
"fields": {
"user": 99,
"name": "test cert",
"courseware": "course.xml",
"allow_certificate": true
}
},
{
"pk": 99,
"model": "student.registration",
"fields": {
"user": 99,
"activation_key": "52bfac10384d49219385dcd4cc17177p"
}
},
{
"pk": 2,
"model": "certificates.certificatehtmlviewconfiguration",
"fields": {
"change_date": "2050-05-15 11:02:13",
"changed_by": 99,
"enabled": true,
"configuration": "{\"default\": {\"accomplishment_class_append\": \"accomplishment-certificate\",\"platform_name\": \"edX\",\"company_privacy_url\": \"http://www.edx.org/edx-privacy-policy\",\"company_about_url\": \"http://www.edx.org/about-us\",\"company_tos_url\": \"http://www.edx.org/edx-terms-service\",\"company_verified_certificate_url\": \"http://www.edx.org/verified-certificate\",\"document_stylesheet_url_application\": \"/static/certificates/sass/main-ltr.css\",\"logo_src\": \"/static/certificates/images/logo-edx.svg\",\"logo_url\": \"http://www.edx.org\"},\"honor\": {\"certificate_type\": \"Honor Code\",\"document_body_class_append\": \"is-honorcode\"},\"verified\": {\"certificate_type\": \"Verified\",\"document_body_class_append\": \"is-idverified\"},\"xseries\": {\"certificate_type\": \"XSeries\",\"document_body_class_append\": \"is-xseries\"}}"
}
},
{
"pk": 1,
"model": "certificates.generatedcertificate",
"fields": {
"user": 99,
"download_url": "http://www.edx.org/certificates/downloand",
"grade": "0.8",
"course_id": "course-v1:test_org+335535897951379478207964576572017930000+test_run",
"key": "",
"distinction": true,
"status": "downloadable",
"verify_uuid": "52bfac10394d49219385dcd4cc17177e",
"download_uuid": "52bfac10394d49219385dcd4cc17177r",
"name": "testcert",
"created_date": "2015-06-12 11:02:13",
"modified_date": "2015-06-12 11:02:13",
"error_reason": "",
"mode": "honor"
}
},
{
"pk": 1,
"model": "student.linkedinaddtoprofileconfiguration",
"fields": {
"change_date": "2050-06-15 11:02:13",
"changed_by": 99,
"enabled": true,
"dashboard_tracking_code": "edx-course-v1&TESTCOURSE",
"company_identifier": "7nTFLiuDkkQkdELSpruCwD4F6jzqtTFsx3PfJUIT2qHqXRLG1",
"trk_partner_name": "edx"
}
}
]
......@@ -9,10 +9,12 @@ import logging
from django.conf import settings
from django.core.urlresolvers import reverse
from eventtracking import tracker
from xmodule.modulestore.django import modulestore
from certificates.models import (
CertificateStatuses as cert_status,
CertificateStatuses,
certificate_status_for_student,
CertificateGenerationCourseSetting,
CertificateGenerationConfiguration,
......@@ -24,13 +26,14 @@ from certificates.queue import XQueueCertInterface
log = logging.getLogger("edx.certificate")
def generate_user_certificates(student, course_key, course=None, insecure=False):
def generate_user_certificates(student, course_key, course=None, insecure=False, generation_mode='batch'):
"""
It will add the add-cert request into the xqueue.
A new record will be created to track the certificate
generation task. If an error occurs while adding the certificate
to the queue, the task will have status 'error'.
to the queue, the task will have status 'error'. It also emits
`edx.certificate.created` event for analytics.
Args:
student (User)
......@@ -40,12 +43,23 @@ def generate_user_certificates(student, course_key, course=None, insecure=False)
course (Course): Optionally provide the course object; if not provided
it will be loaded.
insecure - (Boolean)
generation_mode - who has requested certificate generation. Its value should `batch`
in case of django command and `self` if student initiated the request.
"""
xqueue = XQueueCertInterface()
if insecure:
xqueue.use_https = False
generate_pdf = not has_html_certificates_enabled(course_key, course)
return xqueue.add_cert(student, course_key, course=course, generate_pdf=generate_pdf)
status, cert = xqueue.add_cert(student, course_key, course=course, generate_pdf=generate_pdf)
if status in [CertificateStatuses.generating, CertificateStatuses.downloadable]:
emit_certificate_event('created', student, course_key, course, {
'user_id': student.id,
'course_id': unicode(course_key),
'certificate_id': cert.verify_uuid,
'enrollment_mode': cert.mode,
'generation_mode': generation_mode
})
return status
def regenerate_user_certificates(student, course_key, course=None,
......@@ -95,11 +109,12 @@ def certificate_downloadable_status(student, course_key):
response_data = {
'is_downloadable': False,
'is_generating': True if current_status['status'] in [cert_status.generating, cert_status.error] else False,
'is_generating': True if current_status['status'] in [CertificateStatuses.generating,
CertificateStatuses.error] else False,
'download_url': None
}
if current_status['status'] == cert_status.downloadable:
if current_status['status'] == CertificateStatuses.downloadable:
response_data['is_downloadable'] = True
response_data['download_url'] = current_status['download_url']
......@@ -259,3 +274,26 @@ def get_active_web_certificate(course, is_preview_mode=None):
if config.get('is_active') or is_preview_mode:
return config
return None
def emit_certificate_event(event_name, user, course_id, course=None, event_data=None):
"""
Emits certificate event.
"""
event_name = '.'.join(['edx', 'certificate', event_name])
if course is None:
course = modulestore().get_course(course_id, depth=0)
context = {
'org_id': course.org,
'course_id': unicode(course_id)
}
data = {
'user_id': user.id,
'course_id': unicode(course_id),
'certificate_url': get_certificate_url(user.id, course_id)
}
event_data = event_data or {}
event_data.update(data)
with tracker.get_tracker().context(event_name, context):
tracker.emit(event_name, event_data)
......@@ -81,6 +81,15 @@ class CertificateStatuses(object):
unavailable = 'unavailable'
class CertificateSocialNetworks(object):
"""
Enum for certificate social networks
"""
linkedin = 'LinkedIn'
facebook = 'Facebook'
twitter = 'Twitter'
class CertificateWhitelist(models.Model):
"""
Tracks students who are whitelisted, all users
......@@ -139,10 +148,11 @@ class GeneratedCertificate(models.Model):
def handle_post_cert_generated(sender, instance, **kwargs): # pylint: disable=no-self-argument, unused-argument
"""
Handles post_save signal of GeneratedCertificate, and mark user collected
course milestone entry if user has passed the course
or certificate status is 'generating'.
course milestone entry if user has passed the course.
User is assumed to have passed the course if certificate status is either 'generating' or 'downloadable'.
"""
if settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES') and instance.status == CertificateStatuses.generating:
allowed_cert_states = [CertificateStatuses.generating, CertificateStatuses.downloadable]
if settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES') and instance.status in allowed_cert_states:
fulfill_course_milestone(instance.course_id, instance.user)
......
......@@ -187,7 +187,8 @@ class XQueueCertInterface(object):
will be skipped.
generate_pdf - Boolean should a message be sent in queue to generate certificate PDF
Will change the certificate status to 'generating'.
Will change the certificate status to 'generating' or
`downloadable` in case of web view certificates.
Certificate must be in the 'unavailable', 'error',
'deleted' or 'generating' state.
......@@ -201,7 +202,7 @@ class XQueueCertInterface(object):
If a student does not have a passing grade the status
will change to status.notpassing
Returns the student's status
Returns the student's status and newly created certificate instance
"""
valid_statuses = [
......@@ -215,6 +216,7 @@ class XQueueCertInterface(object):
cert_status = certificate_status_for_student(student, course_id)['status']
new_status = cert_status
cert = None
if cert_status not in valid_statuses:
LOGGER.warning(
......@@ -389,7 +391,7 @@ class XQueueCertInterface(object):
new_status
)
return new_status
return new_status, cert
def add_example_cert(self, example_cert):
"""Add a task to create an example certificate.
......
......@@ -15,6 +15,7 @@ from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from course_modes.tests.factories import CourseModeFactory
from config_models.models import cache
from util.testing import EventTestMixin
from certificates import api as certs_api
from certificates.models import (
......@@ -112,15 +113,19 @@ class CertificateDownloadableStatusTests(ModuleStoreTestCase):
@attr('shard_1')
@override_settings(CERT_QUEUE='certificates')
class GenerateUserCertificatesTest(ModuleStoreTestCase):
class GenerateUserCertificatesTest(EventTestMixin, ModuleStoreTestCase):
"""Tests for generating certificates for students. """
ERROR_REASON = "Kaboom!"
def setUp(self):
super(GenerateUserCertificatesTest, self).setUp()
super(GenerateUserCertificatesTest, self).setUp('certificates.api.tracker')
self.student = UserFactory()
self.student = UserFactory.create(
email='joe_user@edx.org',
username='joeuser',
password='foo'
)
self.student_no_cert = UserFactory()
self.course = CourseFactory.create(
org='edx',
......@@ -139,6 +144,15 @@ class GenerateUserCertificatesTest(ModuleStoreTestCase):
# Verify that the certificate has status 'generating'
cert = GeneratedCertificate.objects.get(user=self.student, course_id=self.course.id)
self.assertEqual(cert.status, CertificateStatuses.generating)
self.assert_event_emitted(
'edx.certificate.created',
user_id=self.student.id,
course_id=unicode(self.course.id),
certificate_url=certs_api.get_certificate_url(self.student.id, self.course.id),
certificate_id=cert.verify_uuid,
enrollment_mode=cert.mode,
generation_mode='batch'
)
def test_xqueue_submit_task_error(self):
with self._mock_passing_grade():
......
......@@ -27,7 +27,8 @@ from certificates.models import (
GeneratedCertificate,
BadgeAssertion,
CertificateStatuses,
CertificateHtmlViewConfiguration
CertificateHtmlViewConfiguration,
CertificateSocialNetworks,
)
from certificates.tests.factories import (
......@@ -593,12 +594,37 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
def test_render_html_view_invalid_certificate_configuration(self):
test_url = get_certificate_url(
user_id=self.user.id,
course_id=unicode(self.course.id) # pylint: disable=no-member
course_id=unicode(self.course.id)
)
response = self.client.get(test_url)
self.assertIn("Invalid Certificate", response.content)
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_certificate_evidence_event_emitted(self):
self.client.logout()
self._add_course_certificates(count=1, signatory_count=2)
self.recreate_tracker()
test_url = get_certificate_url(
user_id=self.user.id,
course_id=unicode(self.course.id)
)
response = self.client.get(test_url)
self.assertEqual(response.status_code, 200)
actual_event = self.get_event()
self.assertEqual(actual_event['name'], 'edx.certificate.evidence_visited')
assert_event_matches(
{
'user_id': self.user.id,
'certificate_id': unicode(self.cert.verify_uuid),
'enrollment_mode': self.cert.mode,
'certificate_url': test_url,
'course_id': unicode(self.course.id),
'social_network': CertificateSocialNetworks.linkedin
},
actual_event['data']
)
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_evidence_event_sent(self):
test_url = get_certificate_url(user_id=self.user.id, course_id=self.course_id) + '?evidence_visit=1'
self.recreate_tracker()
......
......@@ -17,15 +17,21 @@ 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
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,
BadgeAssertion)
from certificates.queue import XQueueCertInterface
CertificateSocialNetworks,
BadgeAssertion
)
from edxmako.shortcuts import render_to_response
from util.views import ensure_valid_course_key
from xmodule.modulestore.django import modulestore
......@@ -588,6 +594,14 @@ def render_html_view(request, user_id, course_id):
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)
......
......@@ -1337,7 +1337,7 @@ def generate_user_cert(request, course_id):
# mark the certificate with "error" status, so it can be re-run
# with a management command. From the user's perspective,
# it will appear that the certificate task was submitted successfully.
certs_api.generate_user_certificates(student, course.id)
certs_api.generate_user_certificates(student, course.id, course=course, generation_mode='self')
_track_successful_certificate_generation(student.id, course.id)
return HttpResponse()
......
......@@ -1318,6 +1318,11 @@ ccx_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/ccx/**/*.js'))
discovery_js = ['js/discovery/main.js']
certificates_web_view_js = [
'js/vendor/jquery.min.js',
'js/vendor/jquery.cookie.js',
'js/src/logger.js',
]
PIPELINE_CSS = {
'style-vendor': {
......@@ -1538,6 +1543,10 @@ PIPELINE_JS = {
'discovery': {
'source_filenames': discovery_js,
'output_filename': 'js/discovery.js'
},
'certificates_wv': {
'source_filenames': certificates_web_view_js,
'output_filename': 'js/certificates/web_view.js'
}
}
......
<%! from django.utils.translation import ugettext as _ %>
<%namespace name='static' file='../static_content.html'/>
<%block name="js_extra">
<%static:js group='certificates_wv'/>
<script type="text/javascript">
$(document).ready(function() {
$.ajaxSetup({
headers: {
'X-CSRFToken': $.cookie('csrftoken')
},
dataType: 'json'
});
$(".action-linkedin-profile").click(function() {
var data = {
user_id: '${accomplishment_user_id}',
course_id: $(this).data('course-id'),
enrollment_mode: $(this).data('certificate-mode'),
certificate_id: '${certificate_id_number}',
certificate_url: window.location.href,
social_network: 'LinkedIn'
};
Logger.log('edx.certificate.shared', data);
});
});
</script>
</%block>
<div class="wrapper-banner wrapper-banner-user">
<section class="banner banner-user">
......@@ -34,4 +58,4 @@
</div>
</div>
</section>
</div>
</div>
\ No newline at end of file
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