diff --git a/common/djangoapps/student/tests/test_filters.py b/common/djangoapps/student/tests/test_filters.py index e379f1482102..6e429fd39a5c 100644 --- a/common/djangoapps/student/tests/test_filters.py +++ b/common/djangoapps/student/tests/test_filters.py @@ -1,10 +1,11 @@ """ Test that various filters are fired for models/views in the student app. """ +from django.http import HttpResponse from django.test import override_settings from django.urls import reverse from openedx_filters import PipelineStep -from openedx_filters.learning.filters import CourseEnrollmentStarted, CourseUnenrollmentStarted +from openedx_filters.learning.filters import DashboardRenderStarted, CourseEnrollmentStarted, CourseUnenrollmentStarted from rest_framework import status from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -43,6 +44,72 @@ def run_filter(self, enrollment): # pylint: disable=arguments-differ return {} +class TestDashboardRenderPipelineStep(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """Pipeline step that modifies dashboard data.""" + context["course_enrollments"] = [] + return { + "context": context, + "template_name": template_name, + } + + +class TestRenderInvalidDashboard(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """Pipeline step that stops the dashboard render process.""" + raise DashboardRenderStarted.RenderInvalidDashboard( + "You can't render this sites dashboard.", + dashboard_template="static_templates/server-error.html" + ) + + +class TestRedirectDashboardPageStep(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """Pipeline step that redirects before the dashboard is rendered.""" + raise DashboardRenderStarted.RedirectToPage( + "You can't see this site's dashboard, redirecting to the correct location.", + redirect_to="https://custom-dashboard.com", + ) + + +class TestRedirectToAccSettingsPage(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """Pipeline step that redirects to account settings before the dashboard is rendered.""" + raise DashboardRenderStarted.RedirectToPage( + "You can't see this site's dashboard, redirecting to the correct location.", + ) + + +class TestRenderCustomResponse(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """Pipeline step that changes dashboard view response before the dashboard is rendered.""" + response = HttpResponse("This is a custom response.") + raise DashboardRenderStarted.RenderCustomResponse( + "You can't see this site's dashboard.", + response=response, + ) + + @skip_unless_lms class EnrollmentFiltersTest(ModuleStoreTestCase): """ @@ -235,3 +302,164 @@ def test_unenrollment_blocked_by_filter(self): self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) self.assertEqual("You can't un-enroll from this site.", response.content.decode("utf-8")) + + +@skip_unless_lms +class StudentDashboardFiltersTest(ModuleStoreTestCase): + """ + Tests for the Open edX Filters associated with the dashboard rendering process. + + This class guarantees that the following filters are triggered during the students dashboard rendering: + - DashboardRenderStarted + """ + + def setUp(self): # pylint: disable=arguments-differ + super().setUp() + self.user = UserFactory() + self.client.login(username=self.user.username, password="test") + self.dashboard_url = reverse("dashboard") + self.first_course = CourseFactory.create( + org="test1", course="course1", display_name="run1", + ) + self.second_course = CourseFactory.create( + org="test2", course="course2", display_name="run1", + ) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.dashboard.render.started.v1": { + "pipeline": [ + "common.djangoapps.student.tests.test_filters.TestDashboardRenderPipelineStep", + ], + "fail_silently": False, + }, + }, + ) + def test_dashboard_render_filter_executed(self): + """ + Test whether the student dashboard filter is triggered before the user's + dashboard rendering process. + + Expected result: + - DashboardRenderStarted is triggered and executes TestDashboardRenderPipelineStep. + - The dashboard is rendered using the filtered enrollments list. + """ + CourseEnrollment.enroll(self.user, self.first_course.id) + CourseEnrollment.enroll(self.user, self.second_course.id) + + response = self.client.get(self.dashboard_url) + + self.assertNotContains(response, self.first_course.id) + self.assertNotContains(response, self.second_course.id) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.dashboard.render.started.v1": { + "pipeline": [ + "common.djangoapps.student.tests.test_filters.TestRenderInvalidDashboard", + ], + "fail_silently": False, + }, + }, + PLATFORM_NAME="My site", + ) + def test_dashboard_render_invalid(self): + """ + Test rendering an invalid template after catching PreventDashboardRender exception. + + Expected result: + - DashboardRenderStarted is triggered and executes TestRenderInvalidDashboard. + - The server error template is rendered instead of the usual dashboard. + """ + response = self.client.get(self.dashboard_url) + + self.assertContains(response, "There has been a 500 error on the My site servers") + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.dashboard.render.started.v1": { + "pipeline": [ + "common.djangoapps.student.tests.test_filters.TestRedirectDashboardPageStep", + ], + "fail_silently": False, + }, + }, + ) + def test_dashboard_redirect(self): + """ + Test redirecting to a new page after catching RedirectDashboardPage exception. + + Expected result: + - DashboardRenderStarted is triggered and executes TestRedirectDashboardPageStep. + - The view response is a redirection. + - The redirection url is the custom dashboard specified in the filter. + """ + response = self.client.get(self.dashboard_url) + + self.assertEqual(status.HTTP_302_FOUND, response.status_code) + self.assertEqual("https://custom-dashboard.com", response.url) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.dashboard.render.started.v1": { + "pipeline": [ + "common.djangoapps.student.tests.test_filters.TestRedirectToAccSettingsPage", + ], + "fail_silently": False, + }, + }, + ) + def test_dashboard_redirect_account_settings(self): + """ + Test redirecting to the account settings page after catching RedirectDashboardPage exception. + + Expected result: + - DashboardRenderStarted is triggered and executes TestRedirectToAccSettingsPage. + - The view response is a redirection. + - The redirection url is the account settings (as the default when not specifying one). + """ + response = self.client.get(self.dashboard_url) + + self.assertEqual(status.HTTP_302_FOUND, response.status_code) + self.assertEqual(reverse("account_settings"), response.url) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.dashboard.render.started.v1": { + "pipeline": [ + "common.djangoapps.student.tests.test_filters.TestRenderCustomResponse", + ], + "fail_silently": False, + }, + }, + ) + def test_dashboard_custom_response(self): + """ + Test returning a custom response after catching RenderCustomResponse exception. + + Expected result: + - DashboardRenderStarted is triggered and executes TestRenderCustomResponse. + - The view response contains the custom response text. + """ + response = self.client.get(self.dashboard_url) + + self.assertEqual("This is a custom response.", response.content.decode("utf-8")) + + @override_settings(OPEN_EDX_FILTERS_CONFIG={}) + def test_dashboard_render_without_filter_config(self): + """ + Test whether the student dashboard filter is triggered before the user's + dashboard rendering process without any modification in the app flow. + + Expected result: + - DashboardRenderStarted executes a noop (empty pipeline). + - The view response is HTTP_200_OK. + - There's no modification in the dashboard. + """ + CourseEnrollment.enroll(self.user, self.first_course.id) + CourseEnrollment.enroll(self.user, self.second_course.id) + + response = self.client.get(self.dashboard_url) + + self.assertContains(response, self.first_course.id) + self.assertContains(response, self.second_course.id) diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index 6f6cff7fb48a..a5d9cf02a465 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -10,6 +10,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.http import HttpResponseRedirect from django.shortcuts import redirect from django.urls import reverse from django.utils.translation import gettext as _ @@ -18,6 +19,7 @@ from edx_django_utils.plugins import get_plugins_view_context from edx_toggles.toggles import LegacyWaffleFlag, LegacyWaffleFlagNamespace from opaque_keys.edx.keys import CourseKey +from openedx_filters.learning.filters import DashboardRenderStarted from pytz import UTC from lms.djangoapps.bulk_email.api import is_bulk_email_feature_enabled @@ -848,7 +850,22 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem 'resume_button_urls': resume_button_urls }) - response = render_to_response('dashboard.html', context) + dashboard_template = 'dashboard.html' + try: + # .. filter_implemented_name: DashboardRenderStarted + # .. filter_type: org.openedx.learning.dashboard.render.started.v1 + context, dashboard_template = DashboardRenderStarted.run_filter( + context=context, template_name=dashboard_template, + ) + except DashboardRenderStarted.RenderInvalidDashboard as exc: + response = render_to_response(exc.dashboard_template, exc.template_context) + except DashboardRenderStarted.RedirectToPage as exc: + response = HttpResponseRedirect(exc.redirect_to or reverse('account_settings')) + except DashboardRenderStarted.RenderCustomResponse as exc: + response = exc.response + else: + response = render_to_response(dashboard_template, context) + if show_account_activation_popup: response.delete_cookie( settings.SHOW_ACTIVATE_CTA_POPUP_COOKIE_NAME, diff --git a/lms/djangoapps/certificates/generation_handler.py b/lms/djangoapps/certificates/generation_handler.py index 8b3834f52868..02fa6d8a71b6 100644 --- a/lms/djangoapps/certificates/generation_handler.py +++ b/lms/djangoapps/certificates/generation_handler.py @@ -8,6 +8,7 @@ import logging from django.conf import settings +from openedx_filters.learning.filters import CertificateCreationRequested from common.djangoapps.course_modes import api as modes_api from common.djangoapps.course_modes.models import CourseMode @@ -28,6 +29,14 @@ log = logging.getLogger(__name__) +class GeneratedCertificateException(Exception): + pass + + +class CertificateGenerationNotAllowed(GeneratedCertificateException): + pass + + def generate_certificate_task(user, course_key, generation_mode=None, delay_seconds=CERTIFICATE_DELAY_SECONDS): """ Create a task to generate a certificate for this user in this course run, if the user is eligible and a certificate @@ -55,9 +64,19 @@ def generate_allowlist_certificate_task(user, course_key, generation_mode=None, enrollment_mode = _get_enrollment_mode(user, course_key) course_grade = _get_course_grade(user, course_key, send_course_grade_signals=False) if _can_generate_allowlist_certificate(user, course_key, enrollment_mode): - return _generate_certificate_task(user=user, course_key=course_key, enrollment_mode=enrollment_mode, - course_grade=course_grade, generation_mode=generation_mode, - delay_seconds=delay_seconds) + try: + return _generate_certificate_task( + user=user, course_key=course_key, enrollment_mode=enrollment_mode, course_grade=course_grade, + generation_mode=generation_mode, delay_seconds=delay_seconds, + ) + except CertificateGenerationNotAllowed: + # Catch exception to contain error message in console. + log.error( + "Certificate generation not allowed for user %s in course %s", + user.id, + course_key, + ) + return False status = _set_allowlist_cert_status(user, course_key, enrollment_mode, course_grade) if status is not None: @@ -94,6 +113,20 @@ def _generate_certificate_task(user, course_key, enrollment_mode, course_grade, course_grade_val = _get_grade_value(course_grade) + try: + # .. filter_implemented_name: CertificateCreationRequested + # .. filter_type: org.openedx.learning.certificate.creation.requested.v1 + user, course_key, enrollment_mode, status, course_grade, generation_mode = CertificateCreationRequested.run_filter( # pylint: disable=line-too-long + user=user, + course_key=course_key, + mode=enrollment_mode, + status=status, + grade=course_grade, + generation_mode=generation_mode, + ) + except CertificateCreationRequested.PreventCertificateCreation as exc: + raise CertificateGenerationNotAllowed(str(exc)) from exc + kwargs = { 'student': str(user.id), 'course_key': str(course_key), diff --git a/lms/djangoapps/certificates/management/commands/cert_generation.py b/lms/djangoapps/certificates/management/commands/cert_generation.py index 122e16a6754d..e80d2c413d46 100644 --- a/lms/djangoapps/certificates/management/commands/cert_generation.py +++ b/lms/djangoapps/certificates/management/commands/cert_generation.py @@ -9,6 +9,7 @@ from django.core.management.base import BaseCommand, CommandError from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey +from lms.djangoapps.certificates.generation_handler import CertificateGenerationNotAllowed from lms.djangoapps.certificates.generation_handler import generate_certificate_task from lms.djangoapps.certificates.models import CertificateGenerationCommandConfiguration @@ -96,4 +97,11 @@ def handle(self, *args, **options): user=user.id, course=course_key )) - generate_certificate_task(user, course_key) + try: + generate_certificate_task(user, course_key) + except CertificateGenerationNotAllowed as e: + log.exception( + "Certificate generation not allowed for user %s in course %s", + user.id, + course_key, + ) diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py index 7fdb77b98261..28e68e340253 100644 --- a/lms/djangoapps/certificates/signals.py +++ b/lms/djangoapps/certificates/signals.py @@ -11,6 +11,7 @@ from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.signals import ENROLLMENT_TRACK_UPDATED from lms.djangoapps.certificates.generation_handler import ( + CertificateGenerationNotAllowed, generate_allowlist_certificate_task, generate_certificate_task, is_on_certificate_allowlist @@ -79,7 +80,15 @@ def listen_for_passing_grade(sender, user, course_id, **kwargs): # pylint: disa return log.info(f'Attempt will be made to generate a course certificate for {user.id} : {course_id} as a passing grade ' f'was received.') - return generate_certificate_task(user, course_id) + try: + return generate_certificate_task(user, course_id) + except CertificateGenerationNotAllowed as e: + log.exception( + "Certificate generation not allowed for user %s in course %s", + str(user), + course_id, + ) + return False @receiver(COURSE_GRADE_NOW_FAILED, dispatch_uid="new_failing_learner") @@ -117,7 +126,14 @@ def _listen_for_id_verification_status_changed(sender, user, **kwargs): # pylin for enrollment in user_enrollments: log.info(f'Attempt will be made to generate a course certificate for {user.id} : {enrollment.course_id}. Id ' f'verification status is {expected_verification_status}') - generate_certificate_task(user, enrollment.course_id) + try: + generate_certificate_task(user, enrollment.course_id) + except CertificateGenerationNotAllowed as e: + log.exception( + "Certificate generation not allowed for user %s in course %s", + str(user), + enrollment.course_id, + ) @receiver(ENROLLMENT_TRACK_UPDATED) @@ -131,4 +147,12 @@ def _listen_for_enrollment_mode_change(sender, user, course_key, mode, **kwargs) if modes_api.is_eligible_for_certificate(mode): log.info(f'Attempt will be made to generate a course certificate for {user.id} : {course_key} since the ' f'enrollment mode is now {mode}.') - generate_certificate_task(user, course_key) + try: + return generate_certificate_task(user, course_key) + except CertificateGenerationNotAllowed as e: + log.exception( + "Certificate generation not allowed for user %s in course %s", + str(user), + course_key, + ) + return False diff --git a/lms/djangoapps/certificates/tests/test_filters.py b/lms/djangoapps/certificates/tests/test_filters.py new file mode 100644 index 000000000000..781b77461f41 --- /dev/null +++ b/lms/djangoapps/certificates/tests/test_filters.py @@ -0,0 +1,379 @@ +""" +Test that various filters are fired for models in the certificates app. +""" +from unittest import mock + +from django.core.management import call_command +from django.test import override_settings +from django.urls import reverse +from openedx_filters import PipelineStep +from openedx_filters.learning.filters import CertificateCreationRequested +from rest_framework import status as status_code +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.roles import SupportStaffRole +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from lms.djangoapps.certificates.generation_handler import ( + CertificateGenerationNotAllowed, + generate_allowlist_certificate_task, + generate_certificate_task +) +from lms.djangoapps.certificates.models import GeneratedCertificate +from lms.djangoapps.certificates.signals import ( + _listen_for_enrollment_mode_change, + _listen_for_id_verification_status_changed, + listen_for_passing_grade +) +from lms.djangoapps.certificates.tests.factories import CertificateAllowlistFactory +from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms + + +class TestCertificatePipelineStep(PipelineStep): + """ + Utility function used when getting steps for pipeline. + """ + + def run_filter(self, user, course_key, mode, status, grade, generation_mode): # pylint: disable=arguments-differ + """Pipeline steps that changes certificate mode from honor to no-id-professional.""" + if mode == 'honor': + return { + 'mode': 'no-id-professional', + } + return {} + + +class TestStopCertificateGenerationStep(PipelineStep): + """ + Utility function used when getting steps for pipeline. + """ + + def run_filter(self, user, course_key, mode, status, grade, generation_mode): # pylint: disable=arguments-differ + """Pipeline step that stops the certificate generation process.""" + raise CertificateCreationRequested.PreventCertificateCreation( + "You can't generate a certificate from this site." + ) + + +@mock.patch( + 'lms.djangoapps.certificates.generation_handler.has_html_certificates_enabled', mock.Mock(return_value=True), +) +@mock.patch('lms.djangoapps.certificates.generation_handler._is_passing_grade', mock.Mock(return_value=True)) +@skip_unless_lms +class CertificateFiltersTest(SharedModuleStoreTestCase): + """ + Tests for the Open edX Filters associated with the certificate generation process. + + This class guarantees that the following filters are triggered during the user's certificate generation: + + - CertificateCreationRequested + """ + + def setUp(self): # pylint: disable=arguments-differ + super().setUp() + self.course_run = CourseFactory() + self.user = UserFactory.create( + username="somestudent", + first_name="Student", + last_name="Person", + email="robot@robot.org", + is_active=True, + password="password", + ) + self.grade = CourseGradeFactory().read(self.user, self.course_run) + self.enrollment = CourseEnrollmentFactory( + user=self.user, + course_id=self.course_run.id, + is_active=True, + mode=CourseMode.HONOR, + ) + self.client.login(username=self.user.username, password="password") + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.certificate.creation.requested.v1": { + "pipeline": [ + "lms.djangoapps.certificates.tests.test_filters.TestCertificatePipelineStep", + ], + "fail_silently": False, + }, + }, + ) + def test_certificate_creation_filter_executed(self): + """ + Test whether the student certificate filter is triggered before the user's + certificate creation process. + + Expected result: + - CertificateCreationRequested is triggered and executes TestCertificatePipelineStep. + - The certificate generates with no-id-professional mode instead of honor mode. + """ + cert_gen_task_created = generate_certificate_task( + self.user, self.course_run.id, generation_mode=CourseMode.HONOR, + ) + + certificate = GeneratedCertificate.objects.get( + user=self.user, + course_id=self.course_run.id, + ) + + self.assertTrue(cert_gen_task_created) + self.assertEqual(CourseMode.NO_ID_PROFESSIONAL_MODE, certificate.mode) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.certificate.creation.requested.v1": { + "pipeline": [ + "lms.djangoapps.certificates.tests.test_filters.TestStopCertificateGenerationStep", + ], + "fail_silently": False, + }, + }, + ) + def test_certificate_creation_filter_prevent_generation(self): + """ + Test prevent the user's certificate generation through a pipeline step. + + Expected result: + - CertificateCreationRequested is triggered and executes TestStopCertificateGenerationStep. + - The certificate is not generated. + """ + with self.assertRaises(CertificateGenerationNotAllowed): + generate_certificate_task( + self.user, self.course_run.id, generation_mode=CourseMode.HONOR, + ) + + self.assertFalse( + GeneratedCertificate.objects.filter( + user=self.user, course_id=self.course_run.id, mode=CourseMode.HONOR, + ) + ) + + @override_settings(OPEN_EDX_FILTERS_CONFIG={}) + def test_certificate_generation_without_filter_configuration(self): + """ + Test usual certificate process, without filter's intervention. + + Expected result: + - CertificateCreationRequested does not have any effect on the certificate generation process. + - The certificate generation process ends successfully. + """ + cert_gen_task_created = generate_certificate_task( + self.user, self.course_run.id, generation_mode=CourseMode.HONOR, + ) + + certificate = GeneratedCertificate.objects.get( + user=self.user, + course_id=self.course_run.id, + ) + + self.assertTrue(cert_gen_task_created) + self.assertEqual(CourseMode.HONOR, certificate.mode) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.certificate.creation.requested.v1": { + "pipeline": [ + "lms.djangoapps.certificates.tests.test_filters.TestStopCertificateGenerationStep", + ], + "fail_silently": False, + }, + }, + ) + def test_generate_allowlist_certificate_fail(self): + """ + Test stop certificate process by raising a filter exception when the user is in the + allow list. + + Expected result: + - CertificateCreationRequested is triggered and executes TestStopCertificateGenerationStep. + - The certificate is not generated. + """ + CertificateAllowlistFactory.create(course_id=self.course_run.id, user=self.user) + + certificate_generated = generate_allowlist_certificate_task(self.user, self.course_run.id) + + self.assertFalse(certificate_generated) + self.assertFalse( + GeneratedCertificate.objects.filter( + user=self.user, course_id=self.course_run.id, mode=CourseMode.HONOR, + ) + ) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.certificate.creation.requested.v1": { + "pipeline": [ + "lms.djangoapps.certificates.tests.test_filters.TestStopCertificateGenerationStep", + ], + "fail_silently": False, + }, + }, + ) + def test_generate_certificate_command(self): + """ + Test stop certificate process through the Django command by raising a filter exception. + + Expected result: + - CertificateCreationRequested is triggered and executes TestStopCertificateGenerationStep. + - The certificate is not generated. + """ + with self.assertLogs(level="ERROR"): + call_command("cert_generation", "--u", self.user.id, "--c", self.course_run.id) + + self.assertFalse( + GeneratedCertificate.objects.filter( + user=self.user, course_id=self.course_run.id, mode=CourseMode.HONOR, + ) + ) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.certificate.creation.requested.v1": { + "pipeline": [ + "lms.djangoapps.certificates.tests.test_filters.TestStopCertificateGenerationStep", + ], + "fail_silently": False, + }, + }, + ) + @mock.patch("lms.djangoapps.certificates.api.auto_certificate_generation_enabled", mock.Mock(return_value=True)) + def test_listen_for_passing_grade(self): + """ + Test stop automatic certificate generation process by raising a filters exception. + + Expected result: + - CertificateCreationRequested is triggered and executes TestStopCertificateGenerationStep. + - The certificate is not generated. + """ + signal_result = listen_for_passing_grade(None, self.user, self.course_run.id) + + self.assertFalse(signal_result) + self.assertFalse( + GeneratedCertificate.objects.filter( + user=self.user, course_id=self.course_run.id, mode=CourseMode.HONOR, + ) + ) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.certificate.creation.requested.v1": { + "pipeline": [ + "lms.djangoapps.certificates.tests.test_filters.TestStopCertificateGenerationStep", + ], + "fail_silently": False, + }, + }, + ) + @mock.patch( + 'lms.djangoapps.verify_student.services.IDVerificationService.user_status', + mock.Mock(return_value={"status": "approved"}) + ) + @mock.patch("lms.djangoapps.certificates.api.auto_certificate_generation_enabled", mock.Mock(return_value=True)) + def test_listen_for_id_verification_status_changed(self): + """ + Test stop certificate generation process after the verification status changed by raising a filters exception. + + Expected result: + - CertificateCreationRequested is triggered and executes TestStopCertificateGenerationStep. + - The certificate is not generated. + """ + _listen_for_id_verification_status_changed(None, self.user) + + self.assertFalse( + GeneratedCertificate.objects.filter( + user=self.user, course_id=self.course_run.id, mode=CourseMode.HONOR, + ) + ) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.certificate.creation.requested.v1": { + "pipeline": [ + "lms.djangoapps.certificates.tests.test_filters.TestStopCertificateGenerationStep", + ], + "fail_silently": False, + }, + }, + ) + def test_listen_for_enrollment_mode_change(self): + """ + Test stop automatic certificate generation process by raising a filters exception. + + Expected result: + - CertificateCreationRequested is triggered and executes TestStopCertificateGenerationStep. + - The certificate is not generated. + """ + signal_result = _listen_for_enrollment_mode_change(None, self.user, self.course_run.id, CourseMode.HONOR) + + self.assertFalse(signal_result) + self.assertFalse( + GeneratedCertificate.objects.filter( + user=self.user, course_id=self.course_run.id, mode=CourseMode.HONOR, + ) + ) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.certificate.creation.requested.v1": { + "pipeline": [ + "lms.djangoapps.certificates.tests.test_filters.TestStopCertificateGenerationStep", + ], + "fail_silently": False, + }, + }, + ) + @mock.patch( + "lms.djangoapps.certificates.generation_handler._can_generate_regular_certificate", + mock.Mock(return_value=True), + ) + def test_generate_cert_support_view(self): + """ + Test stop automatic certificate generation process by raising a filters exception. + + Expected result: + - CertificateCreationRequested is triggered and executes TestStopCertificateGenerationStep. + - The view returns HTTP_400_BAD_REQUEST. + """ + SupportStaffRole().add_users(self.user) + url = reverse( + "certificates:regenerate_certificate_for_user", + ) + body = { + "course_key": str(self.course_run.id), + "username": self.user.username, + } + + response = self.client.post(url, body) + + self.assertEqual(status_code.HTTP_400_BAD_REQUEST, response.status_code) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.certificate.creation.requested.v1": { + "pipeline": [ + "lms.djangoapps.certificates.tests.test_filters.TestStopCertificateGenerationStep", + ], + "fail_silently": False, + }, + }, + ) + def test_generate_cert_progress_view(self): + """ + Test stop certificate generation from the progress view by raising a filters exception. + + Expected result: + - CertificateCreationRequested is triggered and executes TestStopCertificateGenerationStep. + - The view returns HTTP_400_BAD_REQUEST. + """ + url = reverse("generate_user_cert", kwargs={"course_id": str(self.course_run.id)}) + + response = self.client.post(url) + + self.assertContains( + response, + "You can't generate a certificate from this site.", + status_code=status_code.HTTP_400_BAD_REQUEST, + ) diff --git a/lms/djangoapps/certificates/views/support.py b/lms/djangoapps/certificates/views/support.py index 67a75f3fa5cb..7aadf3091952 100644 --- a/lms/djangoapps/certificates/views/support.py +++ b/lms/djangoapps/certificates/views/support.py @@ -22,6 +22,7 @@ from common.djangoapps.student.models import CourseEnrollment, User from common.djangoapps.util.json_request import JsonResponse from lms.djangoapps.certificates.api import generate_certificate_task, get_certificates_for_user +from lms.djangoapps.certificates.generation_handler import CertificateGenerationNotAllowed from lms.djangoapps.certificates.permissions import GENERATE_ALL_CERTIFICATES, VIEW_ALL_CERTIFICATES from lms.djangoapps.instructor_task.api import generate_certificates_for_students from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none @@ -202,6 +203,13 @@ def regenerate_certificate_for_user(request): # Attempt to regenerate certificates try: generate_certificate_task(user, course_key) + except CertificateGenerationNotAllowed as e: + log.exception( + "Certificate generation not allowed for user %s in course %s", + str(user), + course_key, + ) + return HttpResponseBadRequest(str(e)) except: # pylint: disable=bare-except # We are pessimistic about the kinds of errors that might get thrown by the # certificates API. This may be overkill, but we're logging everything so we can diff --git a/lms/djangoapps/certificates/views/tests/__init__.py b/lms/djangoapps/certificates/views/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/certificates/views/tests/test_filters.py b/lms/djangoapps/certificates/views/tests/test_filters.py new file mode 100644 index 000000000000..90b105f8ce00 --- /dev/null +++ b/lms/djangoapps/certificates/views/tests/test_filters.py @@ -0,0 +1,269 @@ +""" +Test that various filters are fired for views in the certificates app. +""" +from django.conf import settings +from django.http import HttpResponse +from django.test import override_settings +from openedx_filters import PipelineStep +from openedx_filters.learning.filters import CertificateRenderStarted +from rest_framework import status +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase + +from lms.djangoapps.certificates.models import CertificateTemplate +from lms.djangoapps.certificates.tests.test_webview_views import CommonCertificatesTestCase +from lms.djangoapps.certificates.utils import get_certificate_url +from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration +from openedx.core.djangolib.testing.utils import skip_unless_lms + +FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() +FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True + + +class TestStopCertificateRenderStep(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, context, custom_template): # pylint: disable=arguments-differ + """Pipeline step that stops the certificate render process.""" + raise CertificateRenderStarted.RenderAlternativeInvalidCertificate( + "You can't generate a certificate from this site.", + ) + + +class TestRedirectToPageStep(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, context, custom_template): # pylint: disable=arguments-differ + """Pipeline step that redirects to another page before rendering the certificate.""" + raise CertificateRenderStarted.RedirectToPage( + "You can't generate a certificate from this site, redirecting to the correct location.", + redirect_to="https://certificate.pdf", + ) + + +class TestRenderCustomResponse(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, context, custom_template): # pylint: disable=arguments-differ + """Pipeline step that returns a custom response when rendering the certificate.""" + response = HttpResponse("Here's the text of the web page.") + raise CertificateRenderStarted.RenderCustomResponse( + "You can't generate a certificate from this site.", + response=response, + ) + + +class TestCertificateRenderPipelineStep(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, context, custom_template): # pylint: disable=arguments-differ + """ + Pipeline step that gets or creates a new custom template to render instead + of the original. + """ + custom_template = self._create_custom_template(mode='honor') + return {"custom_template": custom_template} + + def _create_custom_template(self, org_id=None, mode=None, course_key=None, language=None): + """ + Creates a custom certificate template entry in DB. + """ + template_html = """ + <%namespace name='static' file='static_content.html'/> + +
+ lang: ${LANGUAGE_CODE} + course name: ${accomplishment_copy_course_name} + mode: ${course_mode} + ${accomplishment_copy_course_description} + ${twitter_url} +
+
+
+ """
+ template = CertificateTemplate(
+ name='custom template',
+ template=template_html,
+ organization_id=org_id,
+ course_key=course_key,
+ mode=mode,
+ is_active=True,
+ language=language
+ )
+ template.save()
+ return template
+
+
+@skip_unless_lms
+class CertificateFiltersTest(CommonCertificatesTestCase, SharedModuleStoreTestCase):
+ """
+ Tests for the Open edX Filters associated with the certificate rendering process.
+
+ This class guarantees that the following filters are triggered during the user's certificate rendering:
+
+ - CertificateRenderStarted
+ """
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={
+ "org.openedx.learning.certificate.render.started.v1": {
+ "pipeline": [
+ "lms.djangoapps.certificates.views.tests.test_filters.TestCertificateRenderPipelineStep",
+ ],
+ "fail_silently": False,
+ },
+ },
+ FEATURES=FEATURES_WITH_CERTS_ENABLED,
+ )
+ def test_certificate_render_filter_executed(self):
+ """
+ Test whether the student certificate render filter is triggered before the user's
+ certificate rendering process.
+
+ Expected result:
+ - CertificateRenderStarted is triggered and executes TestCertificateRenderPipelineStep.
+ - The certificate renders using the custom template.
+ """
+ test_url = get_certificate_url(
+ user_id=self.user.id,
+ course_id=str(self.course.id),
+ uuid=self.cert.verify_uuid
+ )
+ self._add_course_certificates(count=1, signatory_count=1, is_active=True)
+
+ response = self.client.get(test_url)
+
+ self.assertContains(
+ response,
+ '
',
+ )
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={
+ "org.openedx.learning.certificate.render.started.v1": {
+ "pipeline": [
+ "lms.djangoapps.certificates.views.tests.test_filters.TestStopCertificateRenderStep",
+ ],
+ "fail_silently": False,
+ },
+ },
+ FEATURES=FEATURES_WITH_CERTS_ENABLED,
+ )
+ def test_certificate_render_invalid(self):
+ """
+ Test rendering an invalid template after catching RenderAlternativeInvalidCertificate exception.
+
+ Expected result:
+ - CertificateRenderStarted is triggered and executes TestStopCertificateRenderStep.
+ - The invalid certificate template is rendered.
+ """
+ test_url = get_certificate_url(
+ user_id=self.user.id,
+ course_id=str(self.course.id),
+ uuid=self.cert.verify_uuid
+ )
+ self._add_course_certificates(count=1, signatory_count=1, is_active=True)
+
+ response = self.client.get(test_url)
+
+ self.assertContains(response, "Invalid Certificate")
+ self.assertContains(response, "Cannot Find Certificate")
+ self.assertContains(response, "We cannot find a certificate with this URL or ID number.")
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={
+ "org.openedx.learning.certificate.render.started.v1": {
+ "pipeline": [
+ "lms.djangoapps.certificates.views.tests.test_filters.TestRedirectToPageStep",
+ ],
+ "fail_silently": False,
+ },
+ },
+ FEATURES=FEATURES_WITH_CERTS_ENABLED,
+ )
+ def test_certificate_redirect(self):
+ """
+ Test redirecting to a new page after catching RedirectToPage exception.
+
+ Expected result:
+ - CertificateRenderStarted is triggered and executes TestRedirectToPageStep.
+ - The webview response is a redirection.
+ """
+ test_url = get_certificate_url(
+ user_id=self.user.id,
+ course_id=str(self.course.id),
+ uuid=self.cert.verify_uuid
+ )
+ self._add_course_certificates(count=1, signatory_count=1, is_active=True)
+
+ response = self.client.get(test_url)
+
+ self.assertEqual(status.HTTP_302_FOUND, response.status_code)
+ self.assertEqual("https://certificate.pdf", response.url)
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={
+ "org.openedx.learning.certificate.render.started.v1": {
+ "pipeline": [
+ "lms.djangoapps.certificates.views.tests.test_filters.TestRenderCustomResponse",
+ ],
+ "fail_silently": False,
+ },
+ },
+ FEATURES=FEATURES_WITH_CERTS_ENABLED,
+ )
+ def test_certificate_render_custom_response(self):
+ """
+ Test rendering an invalid template after catching RenderCustomResponse exception.
+
+ Expected result:
+ - CertificateRenderStarted is triggered and executes TestRenderCustomResponse.
+ - The custom response is found in the certificate.
+ """
+ test_url = get_certificate_url(
+ user_id=self.user.id,
+ course_id=str(self.course.id),
+ uuid=self.cert.verify_uuid
+ )
+ self._add_course_certificates(count=1, signatory_count=1, is_active=True)
+
+ response = self.client.get(test_url)
+
+ self.assertContains(response, "Here's the text of the web page.")
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={},
+ FEATURES=FEATURES_WITH_CERTS_ENABLED,
+ )
+ @with_site_configuration(
+ configuration={
+ 'platform_name': 'My Platform Site',
+ },
+ )
+ def test_certificate_render_without_filter_config(self):
+ """
+ Test whether the student certificate filter is triggered before the user's
+ certificate rendering without affecting its execution flow.
+
+ Expected result:
+ - CertificateRenderStarted executes a noop (empty pipeline).
+ - The webview response is HTTP_200_OK.
+ """
+ test_url = get_certificate_url(
+ user_id=self.user.id,
+ course_id=str(self.course.id),
+ uuid=self.cert.verify_uuid
+ )
+ self._add_course_certificates(count=1, signatory_count=1, is_active=True)
+
+ response = self.client.get(test_url)
+
+ self.assertEqual(status.HTTP_200_OK, response.status_code)
+ self.assertContains(response, "My Platform Site")
diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py
index bf284a60ef62..730b4526457b 100644
--- a/lms/djangoapps/certificates/views/webview.py
+++ b/lms/djangoapps/certificates/views/webview.py
@@ -11,13 +11,14 @@
import pytz
from django.conf import settings
from django.contrib.auth.decorators import login_required
-from django.http import Http404, HttpResponse
+from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.template import RequestContext
from django.utils import translation
from django.utils.encoding import smart_str
from eventtracking import tracker
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
+from openedx_filters.learning.filters import CertificateRenderStarted
from organizations import api as organizations_api
from edx_django_utils.plugins import pluggable_override
@@ -511,7 +512,7 @@ def render_cert_by_uuid(request, certificate_uuid):
test_func=lambda request: request.GET.get('preview', None)
)
@pluggable_override('OVERRIDE_RENDER_CERTIFICATE_VIEW')
-def render_html_view(request, course_id, certificate=None):
+def render_html_view(request, course_id, certificate=None): # pylint: disable=too-many-statements
"""
This public view generates an HTML representation of the specified user and course
If a certificate is not available, we display a "Sorry!" screen instead
@@ -642,8 +643,30 @@ def render_html_view(request, course_id, certificate=None):
# Track certificate view events
_track_certificate_events(request, course, user, user_certificate)
+ try:
+ # .. filter_implemented_name: CertificateRenderStarted
+ # .. filter_type: org.openedx.learning.certificate.render.started.v1
+ context, custom_template = CertificateRenderStarted.run_filter(
+ context=context,
+ custom_template=custom_template,
+ )
+ except CertificateRenderStarted.RenderAlternativeInvalidCertificate as exc:
+ response = _render_invalid_certificate(
+ request,
+ course_id,
+ platform_name,
+ configuration,
+ cert_path=exc.template_name or INVALID_CERTIFICATE_TEMPLATE_PATH,
+ )
+ except CertificateRenderStarted.RedirectToPage as exc:
+ response = HttpResponseRedirect(exc.redirect_to)
+ except CertificateRenderStarted.RenderCustomResponse as exc:
+ response = exc.response
+ else:
+ response = _render_valid_certificate(request, context, custom_template)
+
# Render the certificate
- return _render_valid_certificate(request, context, custom_template)
+ return response
def _get_catalog_data_for_course(course_key):
diff --git a/lms/djangoapps/courseware/tests/test_filters.py b/lms/djangoapps/courseware/tests/test_filters.py
new file mode 100644
index 000000000000..069761f3e67e
--- /dev/null
+++ b/lms/djangoapps/courseware/tests/test_filters.py
@@ -0,0 +1,253 @@
+"""
+Test that various filters are fired for courseware views.
+"""
+from django.http import HttpResponse
+from django.test import override_settings
+from django.urls import reverse
+from openedx_filters import PipelineStep
+from openedx_filters.learning.filters import CourseAboutRenderStarted
+from rest_framework import status
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory
+
+from openedx.core.djangolib.testing.utils import skip_unless_lms
+
+
+class TestRenderInvalidCourseAbout(PipelineStep):
+ """
+ Utility class used when getting steps for pipeline.
+ """
+
+ def run_filter(self, context, template_name): # pylint: disable=arguments-differ
+ """
+ Pipeline step that stops the course about render process.
+
+ When raising PreventCourseAboutRender, this filter overrides the course about
+ template name so the view renders a module-error instead.
+ """
+ raise CourseAboutRenderStarted.RenderInvalidCourseAbout(
+ "You can't access the courses about page.",
+ course_about_template="module-error.html",
+ template_context=context,
+ )
+
+
+class TestRedirectToPage(PipelineStep):
+ """
+ Utility class used when getting steps for pipeline.
+ """
+
+ def run_filter(self, context, template_name): # pylint: disable=arguments-differ
+ """
+ Pipeline step that redirects to the course survey.
+
+ When raising RedirectToPage, this filter uses a redirect_to field handled by
+ the course about view that redirects to that URL.
+ """
+ course_key = str(context.get("course").id)
+ raise CourseAboutRenderStarted.RedirectToPage(
+ "You can't access this courses about page, redirecting to the correct location.",
+ redirect_to=f"courses/{course_key}/survey",
+ )
+
+
+class TestRedirectToDefaultPage(PipelineStep):
+ """
+ Utility class used when getting steps for pipeline.
+ """
+
+ def run_filter(self, context, template_name): # pylint: disable=arguments-differ
+ """
+ Pipeline step that redirects to the default page when redirect_to is not specified.
+
+ When raising RedirectToPage, this filter uses a redirect_to field handled by
+ the course about view that redirects to that URL.
+ """
+ course_key = str(context.get("course").id)
+ raise CourseAboutRenderStarted.RedirectToPage(
+ "You can't access this courses about page, redirecting to the correct location.",
+ )
+
+
+class TestRenderCustomResponse(PipelineStep):
+ """
+ Utility class used when getting steps for pipeline.
+ """
+
+ def run_filter(self, context, template_name): # pylint: disable=arguments-differ
+ """
+ Pipeline step that redirects to the course survey.
+
+ When raising RenderCustomResponse, this filter uses a redirect_to field handled by
+ the course about view that redirects to that URL.
+ """
+ response = HttpResponse("Here's the text of the web page.")
+
+ raise CourseAboutRenderStarted.RenderCustomResponse(
+ "You can't access this courses home page.",
+ response=response,
+ )
+
+
+class TestCourseAboutRender(PipelineStep):
+ """
+ Utility class used when getting steps for pipeline.
+ """
+
+ def run_filter(self, context, template_name): # pylint: disable=arguments-differ
+ """Pipeline that gives staff view to the current user."""
+ context["staff_access"] = True
+ context["studio_url"] = "http://madeup-studio.com"
+ return {
+ "context": context, template_name: template_name,
+ }
+
+
+@skip_unless_lms
+class CourseAboutFiltersTest(ModuleStoreTestCase):
+ """
+ Tests for the Open edX Filters associated with the course about rendering process.
+
+ This class guarantees that the following filters are triggered during the course about rendering:
+
+ - CourseAboutRenderStarted
+ """
+
+ def setUp(self): # pylint: disable=arguments-differ
+ super().setUp()
+ self.course = CourseFactory.create()
+ self.course_about_url = reverse(
+ "about_course",
+ kwargs={
+ "course_id": self.course.id,
+ }
+ )
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={
+ "org.openedx.learning.course_about.render.started.v1": {
+ "pipeline": [
+ "lms.djangoapps.courseware.tests.test_filters.TestCourseAboutRender",
+ ],
+ "fail_silently": False,
+ },
+ },
+ )
+ def test_course_about_render_filter_executed(self):
+ """
+ Test whether the course about filter is triggered before the course about view
+ renders.
+
+ Expected result:
+ - CourseAboutRenderStarted is triggered and executes TestCourseAboutRender.
+ - The course about renders with View About Page in studio.
+ """
+ response = self.client.get(self.course_about_url)
+
+ self.assertContains(response, "View About Page in studio", status_code=200)
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={
+ "org.openedx.learning.course_about.render.started.v1": {
+ "pipeline": [
+ "lms.djangoapps.courseware.tests.test_filters.TestRenderInvalidCourseAbout",
+ ],
+ "fail_silently": False,
+ },
+ },
+ PLATFORM_NAME="My site",
+ )
+ def test_course_about_render_alternative(self):
+ """
+ Test rendering an error template after catching PreventCourseAboutRender exception.
+
+ Expected result:
+ - CourseAboutRenderStarted is triggered and executes TestRenderInvalidCourseAbout.
+ - The module-error template is rendered instead of the usual course about.
+ """
+ response = self.client.get(self.course_about_url)
+
+ self.assertContains(response, "There has been an error on the My site servers")
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={
+ "org.openedx.learning.course_about.render.started.v1": {
+ "pipeline": [
+ "lms.djangoapps.courseware.tests.test_filters.TestRedirectToPage",
+ ],
+ "fail_silently": False,
+ },
+ },
+ )
+ def test_course_about_redirect(self):
+ """
+ Test redirecting to a new page after catching RedirectCourseAboutPage exception.
+
+ Expected result:
+ - CourseAboutRenderStarted is triggered and executes TestRedirectToPage.
+ - The view response is a redirection.
+ """
+ response = self.client.get(self.course_about_url)
+
+ self.assertEqual(status.HTTP_302_FOUND, response.status_code)
+ self.assertEqual(f"courses/{self.course.id}/survey", response.url)
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={
+ "org.openedx.learning.course_about.render.started.v1": {
+ "pipeline": [
+ "lms.djangoapps.courseware.tests.test_filters.TestRedirectToDefaultPage",
+ ],
+ "fail_silently": False,
+ },
+ },
+ )
+ def test_course_about_redirect_default(self):
+ """
+ Test redirecting to the default page after catching RedirectCourseAboutPage exception.
+
+ Expected result:
+ - CourseAboutRenderStarted is triggered and executes TestRedirectToPage.
+ - The view response is a redirection.
+ """
+ response = self.client.get(self.course_about_url)
+
+ self.assertEqual(status.HTTP_302_FOUND, response.status_code)
+ self.assertEqual(f"{reverse('dashboard')}", response.url)
+
+ @override_settings(
+ OPEN_EDX_FILTERS_CONFIG={
+ "org.openedx.learning.course_about.render.started.v1": {
+ "pipeline": [
+ "lms.djangoapps.courseware.tests.test_filters.TestRenderCustomResponse",
+ ],
+ "fail_silently": False,
+ },
+ },
+ )
+ def test_course_about_custom_response(self):
+ """
+ Test redirecting to a new page after catching RenderCustomResponse exception.
+
+ Expected result:
+ - CourseAboutRenderStarted is triggered and executes TestRenderCustomResponse.
+ - The view response is a redirection.
+ """
+ response = self.client.get(self.course_about_url)
+
+ self.assertContains(response, "Here's the text of the web page.")
+
+ @override_settings(OPEN_EDX_FILTERS_CONFIG={})
+ def test_course_about_render_without_filter_config(self):
+ """
+ Test whether the course about filter is triggered before the course about
+ render without affecting its execution flow.
+
+ Expected result:
+ - CourseAboutRenderStarted executes a noop (empty pipeline). Without any
+ modification comparing it with the effects of TestCourseAboutRender.
+ - The view response is HTTP_200_OK.
+ """
+ response = self.client.get(self.course_about_url)
+
+ self.assertNotContains(response, "View About Page in studio", status_code=200)
diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py
index 2091a485c379..d0863efc14ce 100644
--- a/lms/djangoapps/courseware/views/views.py
+++ b/lms/djangoapps/courseware/views/views.py
@@ -38,6 +38,7 @@
from markupsafe import escape
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
+from openedx_filters.learning.filters import CourseAboutRenderStarted
from pytz import UTC
from requests.exceptions import ConnectionError, Timeout # pylint: disable=redefined-builtin
from rest_framework import status
@@ -68,6 +69,7 @@
from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException
from lms.djangoapps.certificates import api as certs_api
from lms.djangoapps.certificates.data import CertificateStatuses
+from lms.djangoapps.certificates.generation_handler import CertificateGenerationNotAllowed
from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.course_goals.models import UserActivity
from lms.djangoapps.course_home_api.toggles import course_home_legacy_is_active, course_home_mfe_progress_tab_is_active
@@ -913,7 +915,7 @@ def post(self, request, course_id):
@ensure_csrf_cookie
@ensure_valid_course_key
@cache_if_anonymous()
-def course_about(request, course_id):
+def course_about(request, course_id): # pylint: disable=too-many-statements
"""
Display the course's about page.
"""
@@ -1025,7 +1027,23 @@ def course_about(request, course_id):
'allow_anonymous': allow_anonymous,
}
- return render_to_response('courseware/course_about.html', context)
+ course_about_template = 'courseware/course_about.html'
+ try:
+ # .. filter_implemented_name: CourseAboutRenderStarted
+ # .. filter_type: org.openedx.learning.course_about.render.started.v1
+ context, course_about_template = CourseAboutRenderStarted.run_filter(
+ context=context, template_name=course_about_template,
+ )
+ except CourseAboutRenderStarted.RenderInvalidCourseAbout as exc:
+ response = render_to_response(exc.course_about_template, exc.template_context)
+ except CourseAboutRenderStarted.RedirectToPage as exc:
+ raise CourseAccessRedirect(exc.redirect_to or reverse('dashboard')) from exc
+ except CourseAboutRenderStarted.RenderCustomResponse as exc:
+ response = exc.response or render_to_response(course_about_template, context)
+ else:
+ response = render_to_response(course_about_template, context)
+
+ return response
@ensure_csrf_cookie
@@ -1628,7 +1646,16 @@ def generate_user_cert(request, course_id):
return HttpResponseBadRequest(_("Course is not valid"))
log.info(f'Attempt will be made to generate a course certificate for {student.id} : {course_key}.')
- certs_api.generate_certificate_task(student, course_key, 'self')
+
+ try:
+ certs_api.generate_certificate_task(student, course_key, 'self')
+ except CertificateGenerationNotAllowed as e:
+ log.exception(
+ "Certificate generation not allowed for user %s in course %s",
+ str(student),
+ course_key,
+ )
+ return HttpResponseBadRequest(str(e))
if not is_course_passed(student, course):
log.info("User %s has not passed the course: %s", student.username, course_id)
diff --git a/lms/static/js/groups/views/cohort_editor.js b/lms/static/js/groups/views/cohort_editor.js
index feb451328174..9da4a0c92a65 100644
--- a/lms/static/js/groups/views/cohort_editor.js
+++ b/lms/static/js/groups/views/cohort_editor.js
@@ -260,16 +260,17 @@
// Show error messages.
this.undelegateViewEvents(this.errorNotifications);
- numErrors = modifiedUsers.unknown.length + modifiedUsers.invalid.length;
+ numErrors = modifiedUsers.unknown.length + modifiedUsers.invalid.length + modifiedUsers.not_allowed.length;
if (numErrors > 0) {
- createErrorDetails = function(unknownUsers, invalidEmails, showAllErrors) {
+ createErrorDetails = function(unknownUsers, invalidEmails, notAllowed, showAllErrors) {
var unknownErrorsShown = showAllErrors ? unknownUsers.length :
Math.min(errorLimit, unknownUsers.length);
var invalidErrorsShown = showAllErrors ? invalidEmails.length :
Math.min(errorLimit - unknownUsers.length, invalidEmails.length);
+ var notAllowedErrorsShown = showAllErrors ? notAllowed.length :
+ Math.min(errorLimit - notAllowed.length, notAllowed.length);
details = [];
-
for (i = 0; i < unknownErrorsShown; i++) {
details.push(interpolate_text(gettext('Unknown username: {user}'),
{user: unknownUsers[i]}));
@@ -278,6 +279,10 @@
details.push(interpolate_text(gettext('Invalid email address: {email}'),
{email: invalidEmails[i]}));
}
+ for (i = 0; i < notAllowedErrorsShown; i++) {
+ details.push(interpolate_text(gettext('Cohort assignment not allowed: {email_or_username}'),
+ {email_or_username: notAllowed[i]}));
+ }
return details;
};
@@ -286,12 +291,12 @@
'{numErrors} learners could not be added to this cohort:', numErrors),
{numErrors: numErrors}
);
- details = createErrorDetails(modifiedUsers.unknown, modifiedUsers.invalid, false);
+ details = createErrorDetails(modifiedUsers.unknown, modifiedUsers.invalid, modifiedUsers.not_allowed, false);
errorActionCallback = function(view) {
view.model.set('actionText', null);
view.model.set('details',
- createErrorDetails(modifiedUsers.unknown, modifiedUsers.invalid, true));
+ createErrorDetails(modifiedUsers.unknown, modifiedUsers.invalid, modifiedUsers.not_allowed, true));
view.render();
};
diff --git a/lms/static/js/spec/groups/views/cohorts_spec.js b/lms/static/js/spec/groups/views/cohorts_spec.js
index fa76052daa23..79acf25fb749 100644
--- a/lms/static/js/spec/groups/views/cohorts_spec.js
+++ b/lms/static/js/spec/groups/views/cohorts_spec.js
@@ -14,6 +14,7 @@ define(['backbone', 'jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers
var catLoversInitialCount = 123,
dogLoversInitialCount = 456,
unknownUserMessage,
+ notAllowedUserMessage,
invalidEmailMessage, createMockCohort, createMockCohorts, createMockContentGroups,
createMockCohortSettingsJson, createMockVerifiedTrackCohortsJson, flushVerifiedTrackCohortRequests,
createCohortsView, cohortsView, requests, respondToRefresh, verifyMessage, verifyNoMessage,
@@ -257,6 +258,10 @@ define(['backbone', 'jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers
return 'Invalid email address: ' + name;
};
+ notAllowedUserMessage = function(email) {
+ return 'Cohort assignment not allowed: ' + email;
+ };
+
beforeEach(function() {
setFixtures('