From d0d737f182e1584123d75deccdf37a6f89e37588 Mon Sep 17 00:00:00 2001 From: ruzniaievdm Date: Tue, 9 Jan 2024 12:37:45 +0200 Subject: [PATCH] feat: [AXIMST-148] API for certificates DRF (#2489) --- .../rest_api/v1/serializers/__init__.py | 1 + .../rest_api/v1/serializers/certificates.py | 49 +++++++++ .../contentstore/rest_api/v1/urls.py | 7 ++ .../rest_api/v1/views/__init__.py | 1 + .../rest_api/v1/views/certificates.py | 103 ++++++++++++++++++ .../v1/views/tests/test_certificates.py | 34 ++++++ cms/djangoapps/contentstore/utils.py | 50 +++++++++ .../contentstore/views/certificates.py | 45 +------- 8 files changed, 249 insertions(+), 41 deletions(-) create mode 100644 cms/djangoapps/contentstore/rest_api/v1/serializers/certificates.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/certificates.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views/tests/test_certificates.py diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py index 74bcb4221fd6..d5fd1619dc47 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py @@ -1,6 +1,7 @@ """ Serializers for v1 contentstore API. """ +from .certificates import CourseCertificatesSerializer from .course_details import CourseDetailsSerializer from .course_rerun import CourseRerunSerializer from .course_team import CourseTeamSerializer diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/certificates.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/certificates.py new file mode 100644 index 000000000000..04d028e24178 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/certificates.py @@ -0,0 +1,49 @@ +""" +API Serializers for certificates page +""" + +from rest_framework import serializers + + +class CertificateSignatorySerializer(serializers.Serializer): + """ + Serializer for representing certificate's signatory. + """ + + id = serializers.IntegerField() + name = serializers.CharField() + organization = serializers.CharField(required=False) + signature_image_path = serializers.CharField() + title = serializers.CharField() + + +class CertificateItemSerializer(serializers.Serializer): + """ + Serializer for representing certificate item created for current course. + """ + + course_title = serializers.CharField(required=False) + description = serializers.CharField() + editing = serializers.BooleanField(required=False) + id = serializers.IntegerField() + is_active = serializers.BooleanField() + name = serializers.CharField() + signatories = CertificateSignatorySerializer(many=True) + version = serializers.IntegerField() + + +class CourseCertificatesSerializer(serializers.Serializer): + """ + Serializer for representing course's certificates. + """ + + certificate_activation_handler_url = serializers.CharField() + certificate_web_view_url = serializers.CharField(allow_null=True) + certificates = CertificateItemSerializer(many=True, allow_null=True) + course_modes = serializers.ListField(child=serializers.CharField()) + has_certificate_modes = serializers.BooleanField() + is_active = serializers.BooleanField() + is_global_staff = serializers.BooleanField() + mfe_proctored_exam_settings_url = serializers.CharField( + required=False, allow_null=True, allow_blank=True + ) diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py index 7dcbcaf1c199..8bfdfcbde025 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -5,6 +5,8 @@ from openedx.core.constants import COURSE_ID_PATTERN from .views import ( + ContainerHandlerView, + CourseCertificatesView, CourseDetailsView, CourseTeamView, CourseIndexView, @@ -101,6 +103,11 @@ CourseRerunView.as_view(), name="course_rerun" ), + re_path( + fr'^certificates/{COURSE_ID_PATTERN}$', + CourseCertificatesView.as_view(), + name="certificates" + ), re_path( fr'^container_handler/{settings.USAGE_KEY_PATTERN}$', ContainerHandlerView.as_view(), diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py index 4b302d4235f1..d7da4f890f72 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -1,6 +1,7 @@ """ Views for v1 contentstore API. """ +from .certificates import CourseCertificatesView from .course_details import CourseDetailsView from .course_index import CourseIndexView from .course_team import CourseTeamView diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/certificates.py b/cms/djangoapps/contentstore/rest_api/v1/views/certificates.py new file mode 100644 index 000000000000..e338786d9c81 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/certificates.py @@ -0,0 +1,103 @@ +""" API Views for course certificates """ + +import edx_api_doc_tools as apidocs +from opaque_keys.edx.keys import CourseKey +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from cms.djangoapps.contentstore.utils import get_certificates_context +from cms.djangoapps.contentstore.rest_api.v1.serializers import ( + CourseCertificatesSerializer, +) +from common.djangoapps.student.auth import has_studio_write_access +from openedx.core.lib.api.view_utils import ( + DeveloperErrorViewMixin, + verify_course_exists, + view_auth_classes, +) +from xmodule.modulestore.django import modulestore + + +@view_auth_classes(is_authenticated=True) +class CourseCertificatesView(DeveloperErrorViewMixin, APIView): + """ + View for course certificate page. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "course_id", apidocs.ParameterLocation.PATH, description="Course ID" + ), + ], + responses={ + 200: CourseCertificatesSerializer, + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + }, + ) + @verify_course_exists() + def get(self, request: Request, course_id: str): + """ + Get an object containing course's certificates. + + **Example Request** + + GET /api/contentstore/v1/certificates/{course_id} + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + The HTTP 200 response contains a single dict that contains keys that + are the course's certificates. + + **Example Response** + + ```json + { + "certificate_activation_handler_url": "/certificates/activation/course-v1:org+101+101/", + "certificate_web_view_url": "///certificates/course/course-v1:org+101+101?preview=honor", + "certificates": [ + { + "course_title": "Course title", + "description": "Description of the certificate", + "editing": false, + "id": 1622146085, + "is_active": false, + "name": "Name of the certificate", + "signatories": [ + { + "id": 268550145, + "name": "name_sign", + "organization": "org", + "signature_image_path": "/asset-v1:org+101+101+type@asset+block@camera.png", + "title": "title_sign" + } + ], + "version": 1 + } + ], + "course_modes": [ + "honor" + ], + "has_certificate_modes": true, + "is_active": false, + "is_global_staff": true, + "mfe_proctored_exam_settings_url": "" + } + ``` + """ + course_key = CourseKey.from_string(course_id) + store = modulestore() + + if not has_studio_write_access(request.user, course_key): + self.permission_denied(request) + + with store.bulk_operations(course_key): + course = modulestore().get_course(course_key) + certificates_context = get_certificates_context(course, request.user) + serializer = CourseCertificatesSerializer(certificates_context) + return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_certificates.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_certificates.py new file mode 100644 index 000000000000..e63cda981ac9 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_certificates.py @@ -0,0 +1,34 @@ +""" +Unit tests for the course's certificate. +""" +from django.urls import reverse +from rest_framework import status + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.views.tests.test_certificates import HelperMethods + +from ...mixins import PermissionAccessMixin + + +class CourseCertificatesViewTest(CourseTestCase, PermissionAccessMixin, HelperMethods): + """ + Tests for CourseCertificatesView. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + "cms.djangoapps.contentstore:v1:certificates", + kwargs={"course_id": self.course.id}, + ) + + def test_success_response(self): + """ + Check that endpoint is valid and success response. + """ + self._add_course_certificates(count=2, signatory_count=2) + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["certificates"]), 2) + self.assertEqual(len(response.data["certificates"][0]["signatories"]), 2) + self.assertEqual(len(response.data["certificates"][1]["signatories"]), 2) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 3b0be88d4d6b..e86ddea4a1b2 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -1910,6 +1910,56 @@ def get_container_handler_context(request, usage_key, course, xblock): # pylint return context +def get_certificates_context(course, user): + """ + Utils is used to get context for container xblock requests. + It is used for both DRF and django views. + """ + + from cms.djangoapps.contentstore.views.certificates import CertificateManager + + course_key = course.id + certificate_url = reverse_course_url('certificates_list_handler', course_key) + course_outline_url = reverse_course_url('course_handler', course_key) + upload_asset_url = reverse_course_url('assets_handler', course_key) + activation_handler_url = reverse_course_url( + handler_name='certificate_activation_handler', + course_key=course_key + ) + course_modes = [ + mode.slug for mode in CourseMode.modes_for_course( + course_id=course_key, include_expired=True + ) if mode.slug != 'audit' + ] + + has_certificate_modes = len(course_modes) > 0 + + if has_certificate_modes: + certificate_web_view_url = get_lms_link_for_certificate_web_view( + course_key=course_key, + mode=course_modes[0] # CourseMode.modes_for_course returns default mode if doesn't find anyone. + ) + else: + certificate_web_view_url = None + + is_active, certificates = CertificateManager.is_activated(course) + context = { + 'context_course': course, + 'certificate_url': certificate_url, + 'course_outline_url': course_outline_url, + 'upload_asset_url': upload_asset_url, + 'certificates': certificates, + 'has_certificate_modes': has_certificate_modes, + 'course_modes': course_modes, + 'certificate_web_view_url': certificate_web_view_url, + 'is_active': is_active, + 'is_global_staff': GlobalStaff().has_user(user), + 'certificate_activation_handler_url': activation_handler_url, + 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_key), + } + return context + + class StudioPermissionsService: """ Service that can provide information about a user's permissions. diff --git a/cms/djangoapps/contentstore/views/certificates.py b/cms/djangoapps/contentstore/views/certificates.py index 3c43ab1065ad..a3fb6687b45f 100644 --- a/cms/djangoapps/contentstore/views/certificates.py +++ b/cms/djangoapps/contentstore/views/certificates.py @@ -37,7 +37,6 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import AssetKey, CourseKey -from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.student.auth import has_studio_write_access from common.djangoapps.student.roles import GlobalStaff @@ -48,9 +47,8 @@ from ..exceptions import AssetNotFoundException from ..utils import ( - get_lms_link_for_certificate_web_view, - get_proctored_exam_settings_url, - reverse_course_url + get_certificates_context, + reverse_course_url, ) from .assets import delete_asset @@ -393,43 +391,8 @@ def certificates_list_handler(request, course_key_string): return JsonResponse({"error": msg}, status=403) if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): - certificate_url = reverse_course_url('certificates_list_handler', course_key) - course_outline_url = reverse_course_url('course_handler', course_key) - upload_asset_url = reverse_course_url('assets_handler', course_key) - activation_handler_url = reverse_course_url( - handler_name='certificate_activation_handler', - course_key=course_key - ) - course_modes = [ - mode.slug for mode in CourseMode.modes_for_course( - course_id=course.id, include_expired=True - ) if mode.slug != 'audit' - ] - - has_certificate_modes = len(course_modes) > 0 - - if has_certificate_modes: - certificate_web_view_url = get_lms_link_for_certificate_web_view( - course_key=course_key, - mode=course_modes[0] # CourseMode.modes_for_course returns default mode if doesn't find anyone. - ) - else: - certificate_web_view_url = None - is_active, certificates = CertificateManager.is_activated(course) - return render_to_response('certificates.html', { - 'context_course': course, - 'certificate_url': certificate_url, - 'course_outline_url': course_outline_url, - 'upload_asset_url': upload_asset_url, - 'certificates': certificates, - 'has_certificate_modes': has_certificate_modes, - 'course_modes': course_modes, - 'certificate_web_view_url': certificate_web_view_url, - 'is_active': is_active, - 'is_global_staff': GlobalStaff().has_user(request.user), - 'certificate_activation_handler_url': activation_handler_url, - 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course.id), - }) + certificates_context = get_certificates_context(course, request.user) + return render_to_response('certificates.html', certificates_context) elif "application/json" in request.META.get('HTTP_ACCEPT'): # Retrieve the list of certificates for the specified course if request.method == 'GET':