diff --git a/docs/docs_settings.py b/docs/docs_settings.py index a7e9963d2b..a260b5dd31 100644 --- a/docs/docs_settings.py +++ b/docs/docs_settings.py @@ -34,6 +34,7 @@ FEATURES["ENABLE_MKTG_SITE"] = False INSTALLED_APPS.extend( [ "cms.djangoapps.contentstore.apps.ContentstoreConfig", + 'cms.djangoapps.modulestore_migrator', "cms.djangoapps.course_creators", "cms.djangoapps.xblock_config.apps.XBlockConfig", "lms.djangoapps.lti_provider", diff --git a/docs/lms-openapi.yaml b/docs/lms-openapi.yaml index cd3082f46a..8057b8b2a5 100644 --- a/docs/lms-openapi.yaml +++ b/docs/lms-openapi.yaml @@ -3523,11 +3523,77 @@ paths: in: path required: true type: string + /dashboard/v0/programs/: + get: + operationId: dashboard_v0_programs_list + description: |- + For a learner, get list of enrolled programs with progress. + If an enterprise UUID ias provided, filter out all non-enterprise enrollments for the learner. + + **Example Request** + + GET /api/dashboard/v1/programs/{enterprise_uuid}/ + + **Parameters** + + * `enterprise_uuid`: UUID of an enterprise customer. + + **Example Response** + + [ + { + "uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c", + "title": "Demonstration Program", + "type": "MicroMasters", + "banner_image": { + "large": { + "url": "http://example.com/images/foo.large.jpg", + "width": 1440, + "height": 480 + }, + "medium": { + "url": "http://example.com/images/foo.medium.jpg", + "width": 726, + "height": 242 + }, + "small": { + "url": "http://example.com/images/foo.small.jpg", + "width": 435, + "height": 145 + }, + "x-small": { + "url": "http://example.com/images/foo.x-small.jpg", + "width": 348, + "height": 116 + } + }, + "authoring_organizations": [ + { + "key": "example" + } + ], + "progress": { + "uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c", + "completed": 0, + "in_progress": 0, + "not_started": 2 + } + } + ] + parameters: [] + responses: + '200': + description: '' + tags: + - dashboard + parameters: [] /dashboard/v0/programs/{enterprise_uuid}/: get: operationId: dashboard_v0_programs_read - summary: For an enterprise learner, get list of enrolled programs with progress. description: |- + For a learner, get list of enrolled programs with progress. + If an enterprise UUID ias provided, filter out all non-enterprise enrollments for the learner. + **Example Request** GET /api/dashboard/v1/programs/{enterprise_uuid}/ @@ -6446,11 +6512,77 @@ paths: tags: - learner_home parameters: [] + /learner_home/v1/programs/: + get: + operationId: learner_home_v1_programs_list + description: |- + For a learner, get list of enrolled programs with progress. + If an enterprise UUID ias provided, filter out all non-enterprise enrollments for the learner. + + **Example Request** + + GET /api/dashboard/v1/programs/{enterprise_uuid}/ + + **Parameters** + + * `enterprise_uuid`: UUID of an enterprise customer. + + **Example Response** + + [ + { + "uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c", + "title": "Demonstration Program", + "type": "MicroMasters", + "banner_image": { + "large": { + "url": "http://example.com/images/foo.large.jpg", + "width": 1440, + "height": 480 + }, + "medium": { + "url": "http://example.com/images/foo.medium.jpg", + "width": 726, + "height": 242 + }, + "small": { + "url": "http://example.com/images/foo.small.jpg", + "width": 435, + "height": 145 + }, + "x-small": { + "url": "http://example.com/images/foo.x-small.jpg", + "width": 348, + "height": 116 + } + }, + "authoring_organizations": [ + { + "key": "example" + } + ], + "progress": { + "uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c", + "completed": 0, + "in_progress": 0, + "not_started": 2 + } + } + ] + parameters: [] + responses: + '200': + description: '' + tags: + - learner_home + parameters: [] /learner_home/v1/programs/{enterprise_uuid}/: get: operationId: learner_home_v1_programs_read - summary: For an enterprise learner, get list of enrolled programs with progress. description: |- + For a learner, get list of enrolled programs with progress. + If an enterprise UUID ias provided, filter out all non-enterprise enrollments for the learner. + **Example Request** GET /api/dashboard/v1/programs/{enterprise_uuid}/ @@ -6912,7 +7044,7 @@ paths: django settings. This is a temporary change as a part of the migration of some legacy pages to MFEs. This is a temporary compatibility layer which will eventually be deprecated. - See [Link to DEPR ticket] for more details. todo: add link + See [DEPR ticket](https://github.com/openedx/edx-platform/issues/37210) for more details. The compatability means that settings from the legacy locations will continue to work but the settings listed below in the `_get_legacy_config` function should be added to the MFE diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py index 3b6a9a2235..d58a70940d 100644 --- a/lms/djangoapps/instructor/tests/test_certificates.py +++ b/lms/djangoapps/instructor/tests/test_certificates.py @@ -35,6 +35,128 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order +@ddt.ddt +class CertificateTaskViewTests(SharedModuleStoreTestCase): + """Tests for the certificate panel of the instructor dash. """ + + @classmethod + def setUpClass(cls): + """ + Set up the test class with a test course and instructor dashboard URL. + """ + super().setUpClass() + cls.course = CourseFactory.create() + cls.url = reverse( + 'instructor_dashboard', + kwargs={'course_id': str(cls.course.id)} + ) + + def setUp(self): + """ + Set up test users and enable certificate generation configuration. + """ + super().setUp() + self.user = UserFactory.create() + self.global_staff = GlobalStaffFactory() + self.instructor = InstructorFactory(course_key=self.course.id) + + # Need to clear the cache for model-based configuration + cache.clear() + + # Enable the certificate generation feature + CertificateGenerationConfiguration.objects.create(enabled=True) + + def _login_as(self, role): + """ + Log in the test client as the specified user role. + """ + user_map = { + "user": self.user.username, + "instructor": self.instructor.username, + "global_staff": self.global_staff.username + } + self.client.login(username=user_map.get(role, "user"), password=self.TEST_PASSWORD) + + def _get_url(self, action): + """ + Build the unified certificate task URL for the given action. + """ + return reverse("certificate_task", kwargs={"course_id": self.course.id, "action": action}) + + def _assert_redirects_to_instructor_dash(self, response): + """Check that the response redirects to the certificates section. """ + expected_redirect = reverse( + 'instructor_dashboard', + kwargs={'course_id': str(self.course.id)} + ) + expected_redirect += '#view-certificates' + self.assertRedirects(response, expected_redirect) + + @ddt.data(True, False) + def test_certificate_generation_enable(self, is_enabled): + """ + Test enabling or disabling self-generated certificates as global staff. + """ + self._login_as("global_staff") + + params = {"certificates-enabled": "true" if is_enabled else "false"} + response = self.client.post( + self._get_url("toggle"), + data=params + ) + + # Expect a redirect back to the instructor dashboard + self._assert_redirects_to_instructor_dash(response) + + # Expect that certificate generation is now enabled for the course + actual_enabled = certs_api.has_self_generated_certificates_enabled(str(self.course.id)) + assert is_enabled == actual_enabled + + @ddt.data("user", "instructor", "global_staff") + def test_certificate_generation(self, role): + """ + Test permission-based access to certificate generation by role. + """ + self._login_as(role) + response = self.client.post(self._get_url("generate")) + actual_status_code = { + "user": 403, + "instructor": 200, + "global_staff": 200 + } + assert response.status_code == actual_status_code[role] + + @ddt.data( + ("downloadable", 200, True, 'Certificate regeneration task has been started. You can view ' + 'the status of the generation task in the "Pending Tasks" section.'), + ("generating", 400, False, 'Please select certificate statuses that lie with ' + 'in "certificate_statuses" entry in POST data.') + ) + @ddt.unpack + def test_certificate_regeneration_status_handling(self, cert_status, expected_status, success, expected_message): + """ + Test certificate regeneration with valid and invalid certificate statuses. + """ + # Create a certificate with the given status + GeneratedCertificateFactory.create( + user=self.user, + course_id=self.course.id, + status=cert_status, + mode='honor' + ) + + self._login_as("global_staff") + response = self.client.post( + self._get_url("regenerate"), + data={'certificate_statuses': [cert_status]}, + ) + + assert response.status_code == expected_status + res_json = response.json() + assert res_json.get('success', False) is success + assert res_json.get('message') == expected_message + + @ddt.ddt class CertificatesInstructorDashTest(SharedModuleStoreTestCase): """Tests for the certificate panel of the instructor dash. """ @@ -138,7 +260,11 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase): self.assertContains(response, 'enable-certificates-submit') self.assertNotContains(response, 'Generate Example Certificates') - @mock.patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @mock.patch.dict(settings.FEATURES, { + 'CERTIFICATES_HTML_VIEW': True, + 'CERTIFICATES_INSTRUCTOR_GENERATION': False + } + ) def test_buttons_for_html_certs_in_self_paced_course(self): """ Tests `Enable Student-Generated Certificates` button is enabled diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index f8b6dc06f4..eae657ed19 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -39,7 +39,7 @@ from opaque_keys.edx.keys import CourseKey, UsageKey from openedx.core.djangoapps.course_groups.cohorts import get_cohort_by_name from rest_framework.exceptions import MethodNotAllowed from rest_framework import serializers, status # lint-amnesty, pylint: disable=wrong-import-order -from rest_framework.permissions import IsAdminUser, IsAuthenticated # lint-amnesty, pylint: disable=wrong-import-order +from rest_framework.permissions import IsAdminUser, IsAuthenticated, BasePermission # lint-amnesty, pylint: disable=wrong-import-order from rest_framework.response import Response # lint-amnesty, pylint: disable=wrong-import-order from rest_framework.views import APIView # lint-amnesty, pylint: disable=wrong-import-order from submissions import api as sub_api # installed from the edx-submissions repository # lint-amnesty, pylint: disable=wrong-import-order @@ -3402,17 +3402,57 @@ def _instructor_dash_url(course_key, section=None): return url -@require_course_permission(permissions.ENABLE_CERTIFICATE_GENERATION) -@require_POST -def enable_certificate_generation(request, course_id=None): - """Enable/disable self-generated certificates for a course. +class HasCertificateActionPermission(BasePermission): + """ + DRF permission class to validate course-level certificate task permissions + based on the `action` URL parameter. + """ - Once self-generated certificates have been enabled, students - who have passed the course will be able to generate certificates. + permission_map = { + 'toggle': permissions.ENABLE_CERTIFICATE_GENERATION, + 'generate': permissions.START_CERTIFICATE_GENERATION, + 'regenerate': permissions.START_CERTIFICATE_REGENERATION, + } - Redirects back to the instructor dashboard once the - setting has been updated. + def has_permission(self, request, view): + """ + Check whether the user has permission to perform the requested certificate action + on the specified course. + """ + course_id = view.kwargs.get('course_id') + action = view.kwargs.get('action') + if not course_id or not action: + return False + + required_perm = self.permission_map.get(action) + if required_perm is None: + return False + + try: + course_key = CourseKey.from_string(course_id) + except (ValueError, TypeError): + return False + + return request.user.has_perm(required_perm, course_key) + + +def toggle_certificate_generation(request, course_id): + """ + Enable or disable student-generated certificates for a course. + + Based on the value of the POST field `certificates-enabled`, this function + updates the course setting to allow or prevent students from generating their + own certificates. This function assumes that permission checks + have already been performed. + + Args: + request (HttpRequest): The incoming POST request. + course_id (str): The course identifier in string format. + + Returns: + HttpResponseRedirect: Redirects back to the instructor dashboard + (certificates section) after updating the course setting. """ course_key = CourseKey.from_string(course_id) is_enabled = (request.POST.get('certificates-enabled', 'false') == 'true') @@ -3420,6 +3460,167 @@ def enable_certificate_generation(request, course_id=None): return redirect(_instructor_dash_url(course_key, section='certificates')) +def start_certificate_generation(request, course_id): + """ + Initiates the generation of certificates for all enrolled students in the course. + + This function triggers an asynchronous background task that generates certificates + for every student enrolled in the specified course. It returns a response payload + containing a confirmation message and the task ID for tracking the task's progress. + + Args: + request (HttpRequest): The HTTP request object. + course_key (CourseKey): The course identifier for which to generate certificates. + + Returns: + dict: A dictionary with a success message and the task ID. + """ + course_key = CourseKey.from_string(course_id) + task = task_api.generate_certificates_for_students(request, course_key) + + return { + "message": _( + "Certificate generation task for all students of this course has been started. " + "You can view the status of the generation task in the \"Pending Tasks\" section." + ), + "task_id": task.task_id + } + + +def start_certificate_regeneration(request, course_id, certificates_statuses): + """ + Initiates regeneration of certificates for students based on given certificate statuses. + + This function triggers a background task that regenerates certificates for students + whose certificates match the provided list of statuses. + + Args: + request (HttpRequest): The HTTP request object. + course_key (CourseKey): The identifier of the course for which certificates are being regenerated. + certificates_statuses (list[str]): A list of certificate statuses to filter the affected certificates. + + Returns: + dict: A dictionary with a success message and success status. + """ + course_key = CourseKey.from_string(course_id) + task_api.regenerate_certificates(request, course_key, certificates_statuses) + + return { + 'message': _( + 'Certificate regeneration task has been started. ' + 'You can view the status of the generation task in the "Pending Tasks" section.' + ), + 'success': True + } + + +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class CertificateTask(DeveloperErrorViewMixin, APIView): + """ + API endpoint for handling certificate-related administrative tasks for a given course. + + Supported actions: + - "toggle": Enable or disable self-generated certificates. + - "generate": Initiate certificate generation for all enrolled students. + - "regenerate": Regenerate certificates based on selected certificate statuses. + + URL pattern: + POST /courses/{course_id}/instructor/api/certificates/{action}/ + + The `action` path parameter determines the task to perform. + The request must be authenticated and the user must have the appropriate permission for the action. + """ + permission_classes = [IsAuthenticated, HasCertificateActionPermission] + + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id, action=None): + """ + Handles POST requests for certificate actions. + + Depending on the `action` parameter, different tasks are performed: + + Args: + request (HttpRequest): The HTTP request object. + course_id (str): The ID of the course on which to perform the action. + action (str, optional): The certificate task to perform. Must be one of: + - "toggle": Enable or disable certificates for the course. No additional + parameters are required. + - "generate": Generate certificates for eligible learners. No additional + parameters are required. + - "regenerate": Regenerate certificates for learners. Requires an additional + parameter in the request body: + - `statuses` (list of str): List of certificate statuses to regenerate + (e.g., ["downloaded", "issued"]). + + Returns: + Response: A DRF Response object containing a success message or error details. + If the `action` is invalid, returns HTTP 400 with an error message. + + Example request body for `regenerate` action: + { + "statuses": ["downloaded", "issued"] + } + """ + if action == "toggle": + return self._handle_toggle(request, course_id) + elif action == "generate": + return self._handle_generate(request, course_id) + elif action == "regenerate": + return self._handle_regenerate(request, course_id) + else: + return Response( + {"error": f"Invalid action: {action}"}, + status=status.HTTP_400_BAD_REQUEST + ) + + def _handle_toggle(self, request, course_id): + """Handle certificate generation toggle.""" + # TODO: Update this to return a proper API response (e.g., {"enabled": true}) + return toggle_certificate_generation(request, course_id) + + def _handle_generate(self, request, course_id): + """Handle certificate generation for all students.""" + payload = start_certificate_generation(request, course_id) + return Response(payload, status=status.HTTP_200_OK) + + def _handle_regenerate(self, request, course_id): + """Handle certificate regeneration based on status.""" + # Validate and extract certificate statuses from the request + serializer = CertificateStatusesSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {'message': _( + 'Please select certificate statuses that ' + 'lie with in "certificate_statuses" entry in POST data.' + )}, + status=status.HTTP_400_BAD_REQUEST + ) + statuses = serializer.validated_data['certificate_statuses'] + payload = start_certificate_regeneration(request, course_id, statuses) + return Response(payload, status=status.HTTP_200_OK) + + +@require_course_permission(permissions.ENABLE_CERTIFICATE_GENERATION) +@require_POST +def enable_certificate_generation(request, course_id=None): + """ + View to toggle self-generated certificate availability for a course. + + This endpoint is protected by course-level permission checks and allows + enabling or disabling student-generated certificates. The logic is handled + by `toggle_certificate_generation`. + + Args: + request (HttpRequest): The incoming POST request. + course_id (str): The course identifier in string format. + + Returns: + HttpResponseRedirect: Redirects to the instructor dashboard after update. + """ + return toggle_certificate_generation(request, course_id) + + @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') class MarkStudentCanSkipEntranceExam(APIView): """ @@ -3471,16 +3672,8 @@ class StartCertificateGeneration(DeveloperErrorViewMixin, APIView): """ Generating certificates for all students enrolled in given course. """ - course_key = CourseKey.from_string(course_id) - task = task_api.generate_certificates_for_students(request, course_key) - message = _('Certificate generation task for all students of this course has been started. ' - 'You can view the status of the generation task in the "Pending Tasks" section.') - response_payload = { - 'message': message, - 'task_id': task.task_id - } - - return JsonResponse(response_payload) + payload = start_certificate_generation(request, course_id=course_id) + return JsonResponse(payload) @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') @@ -3501,7 +3694,6 @@ class StartCertificateRegeneration(DeveloperErrorViewMixin, APIView): """ certificate_statuses 'certificate_statuses' in POST data. """ - course_key = CourseKey.from_string(course_id) serializer = self.serializer_class(data=request.data) if not serializer.is_valid(): @@ -3511,13 +3703,8 @@ class StartCertificateRegeneration(DeveloperErrorViewMixin, APIView): ) certificates_statuses = serializer.validated_data['certificate_statuses'] - task_api.regenerate_certificates(request, course_key, certificates_statuses) - response_payload = { - 'message': _('Certificate regeneration task has been started. ' - 'You can view the status of the generation task in the "Pending Tasks" section.'), - 'success': True - } - return JsonResponse(response_payload) + payload = start_certificate_regeneration(request, course_id, certificates_statuses) + return JsonResponse(payload) @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 90a087443a..7d4db3e3c0 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -82,6 +82,9 @@ urlpatterns = [ # Cohort management path('add_users_to_cohorts', api.AddUsersToCohorts.as_view(), name='add_users_to_cohorts'), + # Unified endpoint for Certificate tasks + path('certificate_task/', api.CertificateTask.as_view(), name='certificate_task'), + # Certificates path('enable_certificate_generation', api.enable_certificate_generation, name='enable_certificate_generation'), path('start_certificate_generation', api.StartCertificateGeneration.as_view(), name='start_certificate_generation'), diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index be2ae51dbd..3c47a67b63 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -385,16 +385,16 @@ def _section_certificates(course): CertificateGenerationHistory.objects.filter(course_id=course.id).order_by("-created"), 'urls': { 'enable_certificate_generation': reverse( - 'enable_certificate_generation', - kwargs={'course_id': course.id} + 'certificate_task', + kwargs={'course_id': course.id, "action": "toggle"} ), 'start_certificate_generation': reverse( - 'start_certificate_generation', - kwargs={'course_id': course.id} + 'certificate_task', + kwargs={'course_id': course.id, "action": "generate"} ), 'start_certificate_regeneration': reverse( - 'start_certificate_regeneration', - kwargs={'course_id': course.id} + 'certificate_task', + kwargs={'course_id': course.id, "action": "regenerate"} ), 'list_instructor_tasks_url': reverse( 'list_instructor_tasks', diff --git a/lms/static/sass/lms-course.scss b/lms/static/sass/lms-course.scss index a089484ac0..738d892257 100644 --- a/lms/static/sass/lms-course.scss +++ b/lms/static/sass/lms-course.scss @@ -27,7 +27,7 @@ @media only screen and (max-width: 767px) { .survey-table .survey-option .visible-mobile-only { - width: calc(100% - 21px) !important; + width: calc(100% - 54px) !important; } .survey-percentage .percentage {