From 090cefcf447977c4c73e36b83d102544b46fc177 Mon Sep 17 00:00:00 2001 From: Giovanni Di Milia Date: Thu, 21 Apr 2016 17:15:06 -0400 Subject: [PATCH] Added REST API for certificates in LMS (#12055) --- lms/djangoapps/certificates/api.py | 84 +++++++++---- lms/djangoapps/certificates/apis/__init__.py | 0 lms/djangoapps/certificates/apis/urls.py | 11 ++ .../certificates/apis/v0/__init__.py | 0 .../certificates/apis/v0/tests/__init__.py | 0 .../certificates/apis/v0/tests/test_views.py | 116 ++++++++++++++++++ lms/djangoapps/certificates/apis/v0/urls.py | 27 ++++ lms/djangoapps/certificates/apis/v0/views.py | 106 ++++++++++++++++ lms/djangoapps/certificates/tests/test_api.py | 104 ++++++++++++++-- lms/urls.py | 4 + 10 files changed, 419 insertions(+), 33 deletions(-) create mode 100644 lms/djangoapps/certificates/apis/__init__.py create mode 100644 lms/djangoapps/certificates/apis/urls.py create mode 100644 lms/djangoapps/certificates/apis/v0/__init__.py create mode 100644 lms/djangoapps/certificates/apis/v0/tests/__init__.py create mode 100644 lms/djangoapps/certificates/apis/v0/tests/test_views.py create mode 100644 lms/djangoapps/certificates/apis/v0/urls.py create mode 100644 lms/djangoapps/certificates/apis/v0/views.py diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index 0bb54fd15c..ae000bfefe 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -8,28 +8,28 @@ import logging from django.conf import settings from django.core.urlresolvers import reverse - from eventtracking import tracker from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey +from branding import api as branding_api from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from xmodule.modulestore.django import modulestore from xmodule_django.models import CourseKeyField from util.organizations_helpers import get_course_organizations from certificates.models import ( - CertificateStatuses, - certificate_status_for_student, - CertificateGenerationCourseSetting, CertificateGenerationConfiguration, - ExampleCertificateSet, - GeneratedCertificate, + CertificateGenerationCourseSetting, + CertificateStatuses, CertificateTemplate, CertificateTemplateAsset, + ExampleCertificateSet, + GeneratedCertificate, + certificate_status_for_student, ) from certificates.queue import XQueueCertInterface -from branding import api as branding_api + log = logging.getLogger("edx.certificate") @@ -43,6 +43,36 @@ def is_passing_status(cert_status): return CertificateStatuses.is_passing_status(cert_status) +def format_certificate_for_user(username, cert): + """ + Helper function to serialize an user certificate. + + Arguments: + username (unicode): The identifier of the user. + cert (GeneratedCertificate): a user certificate + + Returns: dict + """ + return { + "username": username, + "course_key": cert.course_id, + "type": cert.mode, + "status": cert.status, + "grade": cert.grade, + "created": cert.created_date, + "modified": cert.modified_date, + + # NOTE: the download URL is not currently being set for webview certificates. + # In the future, we can update this to construct a URL to the webview certificate + # for courses that have this feature enabled. + "download_url": ( + cert.download_url or get_certificate_url(cert.user.id, cert.course_id) + if cert.status == CertificateStatuses.downloadable + else None + ), + } + + def get_certificates_for_user(username): """ Retrieve certificate information for a particular user. @@ -57,7 +87,7 @@ def get_certificates_for_user(username): [ { "username": "bob", - "course_key": "edX/DemoX/Demo_Course", + "course_key": CourseLocator('edX', 'DemoX', 'Demo_Course', None, None), "type": "verified", "status": "downloadable", "download_url": "http://www.example.com/cert.pdf", @@ -69,28 +99,30 @@ def get_certificates_for_user(username): """ return [ - { - "username": username, - "course_key": cert.course_id, - "type": cert.mode, - "status": cert.status, - "grade": cert.grade, - "created": cert.created_date, - "modified": cert.modified_date, - - # NOTE: the download URL is not currently being set for webview certificates. - # In the future, we can update this to construct a URL to the webview certificate - # for courses that have this feature enabled. - "download_url": ( - cert.download_url or get_certificate_url(cert.user.id, cert.course_id) - if cert.status == CertificateStatuses.downloadable - else None - ), - } + format_certificate_for_user(username, cert) for cert in GeneratedCertificate.eligible_certificates.filter(user__username=username).order_by("course_id") ] +def get_certificate_for_user(username, course_key): + """ + Retrieve certificate information for a particular user for a specific course. + + Arguments: + username (unicode): The identifier of the user. + course_key (CourseKey): A Course Key. + Returns: dict + """ + try: + cert = GeneratedCertificate.eligible_certificates.get( + user__username=username, + course_id=course_key + ) + except GeneratedCertificate.DoesNotExist: + return None + return format_certificate_for_user(username, cert) + + def generate_user_certificates(student, course_key, course=None, insecure=False, generation_mode='batch', forced_grade=None): """ diff --git a/lms/djangoapps/certificates/apis/__init__.py b/lms/djangoapps/certificates/apis/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/certificates/apis/urls.py b/lms/djangoapps/certificates/apis/urls.py new file mode 100644 index 0000000000..2d45e3f712 --- /dev/null +++ b/lms/djangoapps/certificates/apis/urls.py @@ -0,0 +1,11 @@ +""" Certificates API URLs. """ +from django.conf.urls import ( + include, + patterns, + url, +) + +urlpatterns = patterns( + '', + url(r'^v0/', include('lms.djangoapps.certificates.apis.v0.urls', namespace='v0')), +) diff --git a/lms/djangoapps/certificates/apis/v0/__init__.py b/lms/djangoapps/certificates/apis/v0/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/certificates/apis/v0/tests/__init__.py b/lms/djangoapps/certificates/apis/v0/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/certificates/apis/v0/tests/test_views.py b/lms/djangoapps/certificates/apis/v0/tests/test_views.py new file mode 100644 index 0000000000..1a32dde024 --- /dev/null +++ b/lms/djangoapps/certificates/apis/v0/tests/test_views.py @@ -0,0 +1,116 @@ +""" +Tests for the Certificate REST APIs. +""" +from django.core.urlresolvers import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from certificates.models import CertificateStatuses +from certificates.tests.factories import GeneratedCertificateFactory +from course_modes.models import CourseMode +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +class CertificatesRestApiTest(SharedModuleStoreTestCase, APITestCase): + """ + Test for the Certificates REST APIs + """ + @classmethod + def setUpClass(cls): + super(CertificatesRestApiTest, cls).setUpClass() + cls.course = CourseFactory.create( + org='edx', + number='verified', + display_name='Verified Course' + ) + + def setUp(self): + super(CertificatesRestApiTest, self).setUp() + + self.student = UserFactory.create(password='test') + self.student_no_cert = UserFactory.create(password='test') + self.staff_user = UserFactory.create(password='test', is_staff=True) + + GeneratedCertificateFactory.create( + user=self.student, + course_id=self.course.id, + status=CertificateStatuses.downloadable, + mode='verified', + download_url='www.google.com', + grade="0.88" + ) + + self.namespaced_url = 'certificates_api:v0:certificates:detail' + + def get_url(self, username): + """ + Helper function to create the url for certificates + """ + return reverse( + self.namespaced_url, + kwargs={ + 'course_id': self.course.id, + 'username': username + } + ) + + def test_permissions(self): + """ + Test that only the owner of the certificate can access the url + """ + # anonymous user + resp = self.client.get(self.get_url(self.student.username)) + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + # another student + self.client.login(username=self.student_no_cert.username, password='test') + resp = self.client.get(self.get_url(self.student.username)) + # gets 404 instead of 403 for security reasons + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(resp.data, {u'detail': u'Not found.'}) # pylint: disable=no-member + self.client.logout() + + # same student of the certificate + self.client.login(username=self.student.username, password='test') + resp = self.client.get(self.get_url(self.student.username)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.client.logout() + + # staff user + self.client.login(username=self.staff_user.username, password='test') + resp = self.client.get(self.get_url(self.student.username)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + + def test_no_certificate_for_user(self): + """ + Test for case with no certificate available + """ + self.client.login(username=self.student_no_cert.username, password='test') + resp = self.client.get(self.get_url(self.student_no_cert.username)) + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn('error_code', resp.data) # pylint: disable=no-member + self.assertEqual( + resp.data['error_code'], # pylint: disable=no-member + 'no_certificate_for_user' + ) + + def test_certificate_for_user(self): + """ + Tests case user that pulls her own certificate + """ + self.client.login(username=self.student.username, password='test') + resp = self.client.get(self.get_url(self.student.username)) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual( + resp.data, # pylint: disable=no-member + { + 'username': self.student.username, + 'status': CertificateStatuses.downloadable, + 'grade': '0.88', + 'download_url': 'www.google.com', + 'certificate_type': CourseMode.VERIFIED, + 'course_id': unicode(self.course.id), + } + ) diff --git a/lms/djangoapps/certificates/apis/v0/urls.py b/lms/djangoapps/certificates/apis/v0/urls.py new file mode 100644 index 0000000000..f8ddd4cc17 --- /dev/null +++ b/lms/djangoapps/certificates/apis/v0/urls.py @@ -0,0 +1,27 @@ +""" Certificates API v0 URLs. """ + +from django.conf import settings +from django.conf.urls import ( + include, + patterns, + url, +) + +from lms.djangoapps.certificates.apis.v0 import views + + +CERTIFICATES_URLS = patterns( + '', + url( + r'^{username}/courses/{course_id}/$'.format( + username=settings.USERNAME_PATTERN, + course_id=settings.COURSE_ID_PATTERN + ), + views.CertificatesDetailView.as_view(), name='detail' + ), +) + +urlpatterns = patterns( + '', + url(r'^certificates/', include(CERTIFICATES_URLS, namespace='certificates')), +) diff --git a/lms/djangoapps/certificates/apis/v0/views.py b/lms/djangoapps/certificates/apis/v0/views.py new file mode 100644 index 0000000000..22644e6a82 --- /dev/null +++ b/lms/djangoapps/certificates/apis/v0/views.py @@ -0,0 +1,106 @@ +""" API v0 views. """ +import logging + +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from rest_framework.authentication import SessionAuthentication +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_oauth.authentication import OAuth2Authentication + +from lms.djangoapps.certificates.api import get_certificate_for_user +from openedx.core.lib.api import permissions + +log = logging.getLogger(__name__) + + +class CertificatesDetailView(GenericAPIView): + """ + **Use Case** + + * Get the details of a certificate for a specific user in a course. + + **Example Request** + + GET /api/certificates/v0/certificates/{username}/{course_id} + + **GET Parameters** + + A GET request must include the following parameters. + + * username: A string representation of an user's username. + * course_id: A string representation of a Course ID. + + **GET Response Values** + + If the request for information about the Certificate is successful, an HTTP 200 "OK" response + is returned. + + The HTTP 200 response has the following values. + + * username: A string representation of an user's username passed in the request. + + * course_id: A string representation of a Course ID. + + * certificate_type: A string representation of the certificate type. + Can be honor|verified|professional + + * status: A string representation of the certificate status. + + * download_url: A string representation of the certificate url. + + * grade: A string representation of a float for the user's course grade. + + **Example GET Response** + + { + "username": "bob", + "course_id": "edX/DemoX/Demo_Course", + "certificate_type": "verified", + "status": "downloadable", + "download_url": "http://www.example.com/cert.pdf", + "grade": "0.98" + } + """ + + authentication_classes = (OAuth2Authentication, SessionAuthentication,) + permission_classes = (IsAuthenticated, permissions.IsUserInUrlOrStaff) + + def get(self, request, username, course_id): + """ + Gets a certificate information. + + Args: + request (Request): Django request object. + username (string): URI element specifying the user's username. + course_id (string): URI element specifying the course location. + + Return: + A JSON serialized representation of the certificate. + """ + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + log.warning('Course ID string "%s" is not valid', course_id) + return Response( + status=404, + data={'error_code': 'course_id_not_valid'} + ) + + user_cert = get_certificate_for_user(username=username, course_key=course_key) + if user_cert is None: + return Response( + status=404, + data={'error_code': 'no_certificate_for_user'} + ) + return Response( + { + "username": user_cert.get('username'), + "course_id": unicode(user_cert.get('course_key')), + "certificate_type": user_cert.get('type'), + "status": user_cert.get('status'), + "download_url": user_cert.get('download_url'), + "grade": user_cert.get('grade') + } + ) diff --git a/lms/djangoapps/certificates/tests/test_api.py b/lms/djangoapps/certificates/tests/test_api.py index fda6acb811..b76be2add9 100644 --- a/lms/djangoapps/certificates/tests/test_api.py +++ b/lms/djangoapps/certificates/tests/test_api.py @@ -8,16 +8,20 @@ from django.test.utils import override_settings from django.conf import settings from mock import patch from nose.plugins.attrib import attr - from opaque_keys.edx.locator import CourseLocator -from xmodule.modulestore.tests.factories import CourseFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from student.models import CourseEnrollment -from student.tests.factories import UserFactory + +from config_models.models import cache from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory -from config_models.models import cache +from microsite_configuration import microsite +from student.models import CourseEnrollment +from student.tests.factories import UserFactory from util.testing import EventTestMixin +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) from certificates import api as certs_api from certificates.models import ( @@ -30,7 +34,6 @@ from certificates.models import ( from certificates.queue import XQueueCertInterface, XQueueAddToQueueError from certificates.tests.factories import GeneratedCertificateFactory -from microsite_configuration import microsite FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True @@ -200,6 +203,93 @@ class CertificateDownloadableStatusTests(WebCertificateTestMixin, ModuleStoreTes ) +@attr('shard_1') +class CertificateGetTests(SharedModuleStoreTestCase): + """Tests for the `test_get_certificate_for_user` helper function. """ + @classmethod + def setUpClass(cls): + super(CertificateGetTests, cls).setUpClass() + cls.student = UserFactory() + cls.student_no_cert = UserFactory() + cls.course_1 = CourseFactory.create( + org='edx', + number='verified_1', + display_name='Verified Course 1' + ) + cls.course_2 = CourseFactory.create( + org='edx', + number='verified_2', + display_name='Verified Course 2' + ) + # certificate for the first course + GeneratedCertificateFactory.create( + user=cls.student, + course_id=cls.course_1.id, + status=CertificateStatuses.downloadable, + mode='verified', + download_url='www.google.com', + grade="0.88", + ) + # certificate for the second course + GeneratedCertificateFactory.create( + user=cls.student, + course_id=cls.course_2.id, + status=CertificateStatuses.downloadable, + mode='honor', + download_url='www.gmail.com', + grade="0.99", + ) + + def test_get_certificate_for_user(self): + """ + Test to get a certificate for a user for a specific course. + """ + cert = certs_api.get_certificate_for_user(self.student.username, self.course_1.id) + + self.assertEqual(cert['username'], self.student.username) + self.assertEqual(cert['course_key'], self.course_1.id) + self.assertEqual(cert['type'], CourseMode.VERIFIED) + self.assertEqual(cert['status'], CertificateStatuses.downloadable) + self.assertEqual(cert['grade'], "0.88") + self.assertEqual(cert['download_url'], 'www.google.com') + + def test_get_certificates_for_user(self): + """ + Test to get all the certificates for a user + """ + certs = certs_api.get_certificates_for_user(self.student.username) + self.assertEqual(len(certs), 2) + self.assertEqual(certs[0]['username'], self.student.username) + self.assertEqual(certs[1]['username'], self.student.username) + self.assertEqual(certs[0]['course_key'], self.course_1.id) + self.assertEqual(certs[1]['course_key'], self.course_2.id) + self.assertEqual(certs[0]['type'], CourseMode.VERIFIED) + self.assertEqual(certs[1]['type'], CourseMode.HONOR) + self.assertEqual(certs[0]['status'], CertificateStatuses.downloadable) + self.assertEqual(certs[1]['status'], CertificateStatuses.downloadable) + self.assertEqual(certs[0]['grade'], '0.88') + self.assertEqual(certs[1]['grade'], '0.99') + self.assertEqual(certs[0]['download_url'], 'www.google.com') + self.assertEqual(certs[1]['download_url'], 'www.gmail.com') + + def test_no_certificate_for_user(self): + """ + Test the case when there is no certificate for a user for a specific course. + """ + self.assertIsNone( + certs_api.get_certificate_for_user(self.student_no_cert.username, self.course_1.id) + ) + + def test_no_certificates_for_user(self): + """ + Test the case when there are no certificates for a user. + """ + self.assertEqual( + certs_api.get_certificates_for_user(self.student_no_cert.username), + [] + ) + + @attr('shard_1') @override_settings(CERT_QUEUE='certificates') class GenerateUserCertificatesTest(EventTestMixin, WebCertificateTestMixin, ModuleStoreTestCase): diff --git a/lms/urls.py b/lms/urls.py index a4e18e3433..e5c64346b5 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -925,6 +925,10 @@ urlpatterns += ( url(r'^update_certificate$', 'certificates.views.update_certificate'), url(r'^update_example_certificate$', 'certificates.views.update_example_certificate'), url(r'^request_certificate$', 'certificates.views.request_certificate'), + + # REST APIs + url(r'^api/certificates/', + include('lms.djangoapps.certificates.apis.urls', namespace='certificates_api')), ) # XDomain proxy