Merge pull request #19884 from edx/douglashall/course_certificate_list_api

Add list endpoint to course certificate API.
This commit is contained in:
Douglas Hall
2019-03-01 15:55:26 -05:00
committed by GitHub
5 changed files with 343 additions and 28 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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'

View File

@@ -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

View File

@@ -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),