Merge pull request #19884 from edx/douglashall/course_certificate_list_api
Add list endpoint to course certificate API.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user