diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index b08d016c4b..e2b53ee26b 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -63,13 +63,9 @@ def format_certificate_for_user(username, cert): "created": cert.created_date, "modified": cert.modified_date, "is_passing": is_passing_status(cert.status), - - # 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. "is_pdf_certificate": bool(cert.download_url), "download_url": ( - cert.download_url or get_certificate_url(cert.user.id, cert.course_id) + get_certificate_url(cert.user.id, cert.course_id, user_certificate=cert) if cert.status == CertificateStatuses.downloadable else None ), @@ -432,24 +428,28 @@ def _certificate_html_url(user_id, course_id, uuid): return '' -def _certificate_download_url(user_id, course_id): +def _certificate_download_url(user_id, course_id, user_certificate=None): """ :param user_id: :param course_id: :return: """ - try: - user_certificate = GeneratedCertificate.eligible_certificates.get( - user=user_id, - course_id=_safe_course_key(course_id) - ) + if not user_certificate: + try: + user_certificate = GeneratedCertificate.eligible_certificates.get( + user=user_id, + course_id=_safe_course_key(course_id) + ) + except GeneratedCertificate.DoesNotExist: + log.critical( + u'Unable to lookup certificate\n' + u'user id: %d\n' + u'course: %s', user_id, unicode(course_id) + ) + + if user_certificate: return user_certificate.download_url - except GeneratedCertificate.DoesNotExist: - log.critical( - u'Unable to lookup certificate\n' - u'user id: %d\n' - u'course: %s', user_id, unicode(course_id) - ) + return '' @@ -459,7 +459,7 @@ def has_html_certificates_enabled(course): return course.cert_html_view_enabled -def get_certificate_url(user_id=None, course_id=None, uuid=None): +def get_certificate_url(user_id=None, course_id=None, uuid=None, user_certificate=None): """ :param user_id: :param course_id: @@ -475,7 +475,7 @@ def get_certificate_url(user_id=None, course_id=None, uuid=None): if has_html_certificates_enabled(course): url = _certificate_html_url(user_id, course_id, uuid) else: - url = _certificate_download_url(user_id, course_id) + url = _certificate_download_url(user_id, course_id, user_certificate=user_certificate) return url diff --git a/lms/djangoapps/certificates/apis/v0/tests/test_views.py b/lms/djangoapps/certificates/apis/v0/tests/test_views.py index a6d6f3c705..534c7d3f77 100644 --- a/lms/djangoapps/certificates/apis/v0/tests/test_views.py +++ b/lms/djangoapps/certificates/apis/v0/tests/test_views.py @@ -1,27 +1,29 @@ """ Tests for the Certificate REST APIs. """ -# pylint: disable=missing-docstring -import ddt +from itertools import product +import ddt from django.urls import reverse from django.utils import timezone from freezegun import freeze_time +from mock import patch from rest_framework import status from rest_framework.test import APITestCase from course_modes.models import CourseMode -from lms.djangoapps.certificates.apis.v0.views import CertificatesDetailView +from lms.djangoapps.certificates.apis.v0.views import CertificatesDetailView, CertificatesListView from lms.djangoapps.certificates.models import CertificateStatuses from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory -from openedx.core.djangoapps.user_authn.tests.utils import AuthType, AuthAndScopesTestMixin +from openedx.core.djangoapps.oauth_dispatch.toggles import ENFORCE_JWT_SCOPES +from openedx.core.djangoapps.user_authn.tests.utils import AuthType, AuthAndScopesTestMixin, JWT_AUTH_TYPES from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @ddt.ddt -class CertificatesRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestCase, APITestCase): +class CertificatesDetailRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestCase, APITestCase): """ Test for the Certificates REST APIs """ @@ -30,7 +32,7 @@ class CertificatesRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestCase, @classmethod def setUpClass(cls): - super(CertificatesRestApiTest, cls).setUpClass() + super(CertificatesDetailRestApiTest, cls).setUpClass() cls.course = CourseFactory.create( org='edx', number='verified', @@ -42,7 +44,7 @@ class CertificatesRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestCase, freezer.start() self.addCleanup(freezer.stop) - super(CertificatesRestApiTest, self).setUp() + super(CertificatesDetailRestApiTest, self).setUp() GeneratedCertificateFactory.create( user=self.student, @@ -94,3 +96,168 @@ class CertificatesRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestCase, resp.data['error_code'], 'no_certificate_for_user', ) + + +@ddt.ddt +class CertificatesListRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestCase, APITestCase): + """ + Test for the Certificates REST APIs + """ + now = timezone.now() + default_scopes = CertificatesListView.required_scopes + + @classmethod + def setUpClass(cls): + super(CertificatesListRestApiTest, cls).setUpClass() + cls.course = CourseFactory.create( + org='edx', + number='verified', + display_name='Verified Course', + self_paced=True, + ) + + def setUp(self): + freezer = freeze_time(self.now) + freezer.start() + self.addCleanup(freezer.stop) + + super(CertificatesListRestApiTest, self).setUp() + + 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:list' + + def get_url(self, username): + """ This method is required by AuthAndScopesTestMixin. """ + return reverse( + self.namespaced_url, + kwargs={ + 'username': username + } + ) + + def assert_success_response_for_student(self, response): + """ This method is required by AuthAndScopesTestMixin. """ + self.assertEqual( + response.data, + [{ + 'username': self.student.username, + 'course_id': unicode(self.course.id), + 'course_display_name': self.course.display_name, + 'course_organization': self.course.org, + 'certificate_type': CourseMode.VERIFIED, + 'created_date': self.now, + 'status': CertificateStatuses.downloadable, + 'is_passing': True, + 'download_url': 'www.google.com', + 'grade': '0.88', + }] + ) + + @patch('edx_rest_framework_extensions.permissions.log') + @ddt.data(*product(list(AuthType), (True, False))) + @ddt.unpack + def test_another_user(self, auth_type, scopes_enforced, mock_log): + """ + Returns 200 with empty list for OAuth, Session, and JWT auth. + Returns 200 for jwt_restricted and user:me filter unset. + """ + with ENFORCE_JWT_SCOPES.override(active=scopes_enforced): + resp = self.get_response(auth_type, requesting_user=self.other_student) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(len(resp.data), 0) + + @patch('edx_rest_framework_extensions.permissions.log') + @ddt.data(*product(JWT_AUTH_TYPES, (True, False))) + @ddt.unpack + def test_jwt_on_behalf_of_other_user(self, auth_type, scopes_enforced, mock_log): + """ Returns 403 when scopes are enforced with JwtHasUserFilterForRequestedUser. """ + with ENFORCE_JWT_SCOPES.override(active=scopes_enforced): + jwt_token = self._create_jwt_token(self.other_student, auth_type, include_me_filter=True) + resp = self.get_response(AuthType.jwt, token=jwt_token) + + if scopes_enforced and auth_type == AuthType.jwt_restricted: + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + self._assert_in_log("JwtHasUserFilterForRequestedUser", mock_log.warning) + else: + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(len(resp.data), 0) + + @patch('edx_rest_framework_extensions.permissions.log') + @ddt.data(*product(JWT_AUTH_TYPES, (True, False))) + @ddt.unpack + def test_jwt_no_filter(self, auth_type, scopes_enforced, mock_log): + self.assertTrue(True) # pylint: disable=redundant-unittest-assert + + def test_no_certificate(self): + student_no_cert = UserFactory.create(password=self.user_password) + resp = self.get_response( + AuthType.session, + requesting_user=student_no_cert, + requested_user=student_no_cert, + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data, []) + + def test_query_counts(self): + # Test student with no certificates + student_no_cert = UserFactory.create(password=self.user_password) + with self.assertNumQueries(21): + resp = self.get_response( + AuthType.jwt, + requesting_user=student_no_cert, + requested_user=student_no_cert, + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(len(resp.data), 0) + + # Test student with 1 certificate + with self.assertNumQueries(29): + resp = self.get_response( + AuthType.jwt, + requesting_user=self.student, + requested_user=self.student, + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(len(resp.data), 1) + + # Test student with 2 certificates + student_2_certs = UserFactory.create(password=self.user_password) + course = CourseFactory.create( + org='edx', + number='test', + display_name='Test Course', + self_paced=True, + ) + GeneratedCertificateFactory.create( + user=student_2_certs, + course_id=self.course.id, + status=CertificateStatuses.downloadable, + mode='verified', + download_url='www.google.com', + grade="0.88", + ) + GeneratedCertificateFactory.create( + user=student_2_certs, + course_id=course.id, + status=CertificateStatuses.downloadable, + mode='verified', + download_url='www.google.com', + grade="0.88", + ) + with self.assertNumQueries(29): + resp = self.get_response( + AuthType.jwt, + requesting_user=student_2_certs, + requested_user=student_2_certs, + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(len(resp.data), 2) diff --git a/lms/djangoapps/certificates/apis/v0/urls.py b/lms/djangoapps/certificates/apis/v0/urls.py index 980dc7136c..d2eba86785 100644 --- a/lms/djangoapps/certificates/apis/v0/urls.py +++ b/lms/djangoapps/certificates/apis/v0/urls.py @@ -14,6 +14,12 @@ CERTIFICATES_URLS = ([ ), views.CertificatesDetailView.as_view(), name='detail' ), + url( + r'^{username}/$'.format( + username=settings.USERNAME_PATTERN + ), + views.CertificatesListView.as_view(), name='list' + ), ], 'certificates') app_name = 'v0' diff --git a/lms/djangoapps/certificates/apis/v0/views.py b/lms/djangoapps/certificates/apis/v0/views.py index 2db8a5551d..98cfb971a6 100644 --- a/lms/djangoapps/certificates/apis/v0/views.py +++ b/lms/djangoapps/certificates/apis/v0/views.py @@ -1,15 +1,19 @@ """ API v0 views. """ import logging +from rest_condition import C from rest_framework.generics import GenericAPIView +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from edx_rest_framework_extensions import permissions from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser -from lms.djangoapps.certificates.api import get_certificate_for_user +from lms.djangoapps.certificates.api import get_certificate_for_user, get_certificates_for_user from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey +from openedx.core.djangoapps.certificates.api import certificates_viewable_for_course +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser @@ -120,3 +124,132 @@ class CertificatesDetailView(GenericAPIView): "grade": user_cert.get('grade') } ) + + +class CertificatesListView(GenericAPIView): + """ + **Use Case** + + * Get the list of viewable course certificates for a specific user. + + **Example Request** + + GET /api/certificates/v0/certificates/{username} + + **GET Parameters** + + A GET request must include the following parameters. + + * username: A string representation of an user's username. + + **GET Response Values** + + If the request for information about the user's certificates is successful, + an HTTP 200 "OK" response is returned. + + The HTTP 200 response contains a list of dicts with the following keys/values. + + * username: A string representation of an user's username passed in the request. + + * course_id: A string representation of a Course ID. + + * course_display_name: A string representation of the Course name. + + * course_organization: A string representation of the organization associated with the Course. + + * certificate_type: A string representation of the certificate type. + Can be honor|verified|professional + + * created_date: Date/time the certificate was created, in ISO-8661 format. + + * status: A string representation of the certificate status. + + * is_passing: True if the certificate has a passing status, False if not. + + * 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", + "created_date": "2015-12-03T13:14:28+0000", + "status": "downloadable", + "is_passing": true, + "download_url": "http://www.example.com/cert.pdf", + "grade": "0.98" + }] + """ + + authentication_classes = ( + JwtAuthentication, + OAuth2AuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + + permission_classes = ( + C(IsAuthenticated) & ( + C(permissions.NotJwtRestrictedApplication) | + ( + C(permissions.JwtRestrictedApplication) & + permissions.JwtHasScope & + permissions.JwtHasUserFilterForRequestedUser + ) + ), + ) + + required_scopes = ['certificates:read'] + + def get(self, request, username): + """ + Gets the list of viewable course certificates for a specific user. + + Args: + request (Request): Django request object. + username (string): URI element specifying the user's username. + + Return: + A JSON serialized representation of the list of certificates. + """ + user_certs = [] + if request.user.username == username or request.user.is_staff: + for user_cert in self._get_certificates_for_user(username): + user_certs.append({ + 'username': user_cert.get('username'), + 'course_id': unicode(user_cert.get('course_key')), + 'course_display_name': user_cert.get('course_display_name'), + 'course_organization': user_cert.get('course_organization'), + 'certificate_type': user_cert.get('type'), + 'created_date': user_cert.get('created'), + 'status': user_cert.get('status'), + 'is_passing': user_cert.get('is_passing'), + 'download_url': user_cert.get('download_url'), + 'grade': user_cert.get('grade'), + }) + + return Response(user_certs) + + def _get_certificates_for_user(self, username): + """ + Returns a user's viewable certificates sorted by course name. + """ + course_certificates = get_certificates_for_user(username) + passing_certificates = {} + for course_certificate in course_certificates: + if course_certificate.get('is_passing', False): + course_key = course_certificate['course_key'] + passing_certificates[course_key] = course_certificate + + viewable_certificates = [] + for course_key, course_overview in CourseOverview.get_from_ids_if_exists(passing_certificates.keys()).items(): + if certificates_viewable_for_course(course_overview): + course_certificate = passing_certificates[course_key] + course_certificate['course_display_name'] = course_overview.display_name_with_default + course_certificate['course_organization'] = course_overview.display_org_with_default + viewable_certificates.append(course_certificate) + + viewable_certificates.sort(key=lambda certificate: certificate['created']) + return viewable_certificates diff --git a/lms/djangoapps/certificates/tests/test_support_views.py b/lms/djangoapps/certificates/tests/test_support_views.py index 6e6b3491aa..5565ee2fec 100644 --- a/lms/djangoapps/certificates/tests/test_support_views.py +++ b/lms/djangoapps/certificates/tests/test_support_views.py @@ -12,6 +12,7 @@ from opaque_keys.edx.keys import CourseKey from lms.djangoapps.certificates.models import CertificateInvalidation, CertificateStatuses, GeneratedCertificate from lms.djangoapps.certificates.tests.factories import CertificateInvalidationFactory +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from student.models import CourseEnrollment from student.roles import GlobalStaff, SupportStaffRole from student.tests.factories import UserFactory @@ -97,7 +98,11 @@ class CertificateSearchTests(CertificateSupportTestCase): Create a course """ super(CertificateSearchTests, self).setUp() - self.course = CourseFactory() + self.course = CourseFactory( + org=self.CERT_COURSE_KEY.org, + course=self.CERT_COURSE_KEY.course, + run=self.CERT_COURSE_KEY.run, + ) self.course.cert_html_view_enabled = True #course certificate configurations @@ -116,6 +121,10 @@ class CertificateSearchTests(CertificateSupportTestCase): self.course.certificates = {'certificates': certificates} self.course.save() self.store.update_item(self.course, self.user.id) + self.course_overview = CourseOverviewFactory( + id=self.course.id, + cert_html_view_enabled=True, + ) @ddt.data( (GlobalStaff, True),