From 6afaa3cce3b159f9d6abdcae3794521a0559fab5 Mon Sep 17 00:00:00 2001 From: Zia Fazal Date: Tue, 9 Jun 2015 17:45:00 +0500 Subject: [PATCH] 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 --- .../test/acceptance/fixtures/certificates.py | 46 ++++++++++ .../acceptance/pages/lms/certificate_page.py | 52 +++++++++++ .../tests/lms/test_certificate_web_view.py | 86 +++++++++++++++++++ .../db_fixtures/certificates_web_view.json | 76 ++++++++++++++++ lms/djangoapps/certificates/api.py | 50 +++++++++-- lms/djangoapps/certificates/models.py | 16 +++- lms/djangoapps/certificates/queue.py | 8 +- lms/djangoapps/certificates/tests/test_api.py | 20 ++++- .../certificates/tests/test_views.py | 30 ++++++- lms/djangoapps/certificates/views.py | 20 ++++- lms/djangoapps/courseware/views.py | 2 +- lms/envs/common.py | 9 ++ .../certificates/_accomplishment-banner.html | 26 +++++- 13 files changed, 419 insertions(+), 22 deletions(-) create mode 100644 common/test/acceptance/fixtures/certificates.py create mode 100644 common/test/acceptance/pages/lms/certificate_page.py create mode 100644 common/test/acceptance/tests/lms/test_certificate_web_view.py create mode 100644 common/test/db_fixtures/certificates_web_view.json diff --git a/common/test/acceptance/fixtures/certificates.py b/common/test/acceptance/fixtures/certificates.py new file mode 100644 index 0000000000..f12573cad9 --- /dev/null +++ b/common/test/acceptance/fixtures/certificates.py @@ -0,0 +1,46 @@ +""" +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 diff --git a/common/test/acceptance/pages/lms/certificate_page.py b/common/test/acceptance/pages/lms/certificate_page.py new file mode 100644 index 0000000000..89c1ac0723 --- /dev/null +++ b/common/test/acceptance/pages/lms/certificate_page.py @@ -0,0 +1,52 @@ +# -*- 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') diff --git a/common/test/acceptance/tests/lms/test_certificate_web_view.py b/common/test/acceptance/tests/lms/test_certificate_web_view.py new file mode 100644 index 0000000000..b069ee654e --- /dev/null +++ b/common/test/acceptance/tests/lms/test_certificate_web_view.py @@ -0,0 +1,86 @@ +""" +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) diff --git a/common/test/db_fixtures/certificates_web_view.json b/common/test/db_fixtures/certificates_web_view.json new file mode 100644 index 0000000000..541ef05625 --- /dev/null +++ b/common/test/db_fixtures/certificates_web_view.json @@ -0,0 +1,76 @@ +[ + { + "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" + } + } +] diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index f121f34f69..311801dd0f 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -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) diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 2eb48d68a4..e7ed44964f 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -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) diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 31c9e8e24f..6a527bd05c 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -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. diff --git a/lms/djangoapps/certificates/tests/test_api.py b/lms/djangoapps/certificates/tests/test_api.py index 2499deb350..bd77a2ff94 100644 --- a/lms/djangoapps/certificates/tests/test_api.py +++ b/lms/djangoapps/certificates/tests/test_api.py @@ -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(): diff --git a/lms/djangoapps/certificates/tests/test_views.py b/lms/djangoapps/certificates/tests/test_views.py index 4798ff1508..979603a82a 100644 --- a/lms/djangoapps/certificates/tests/test_views.py +++ b/lms/djangoapps/certificates/tests/test_views.py @@ -27,7 +27,8 @@ from certificates.models import ( GeneratedCertificate, BadgeAssertion, CertificateStatuses, - CertificateHtmlViewConfiguration + CertificateHtmlViewConfiguration, + CertificateSocialNetworks, ) from certificates.tests.factories import ( @@ -593,11 +594,36 @@ 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' diff --git a/lms/djangoapps/certificates/views.py b/lms/djangoapps/certificates/views.py index 1d54d17541..343b8572e6 100644 --- a/lms/djangoapps/certificates/views.py +++ b/lms/djangoapps/certificates/views.py @@ -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) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 125b70b3be..7bf6a2e503 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -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() diff --git a/lms/envs/common.py b/lms/envs/common.py index 0b53750b48..d7da920750 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1315,6 +1315,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': { @@ -1535,6 +1540,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' } } diff --git a/lms/templates/certificates/_accomplishment-banner.html b/lms/templates/certificates/_accomplishment-banner.html index 35daef51db..d835caad1c 100644 --- a/lms/templates/certificates/_accomplishment-banner.html +++ b/lms/templates/certificates/_accomplishment-banner.html @@ -1,5 +1,29 @@ <%! from django.utils.translation import ugettext as _ %> <%namespace name='static' file='../static_content.html'/> +<%block name="js_extra"> + <%static:js group='certificates_wv'/> + +
- + \ No newline at end of file