Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 229 additions & 1 deletion common/djangoapps/student/tests/test_filters.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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 <em>My site</em> 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)
19 changes: 18 additions & 1 deletion common/djangoapps/student/views/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 _
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 36 additions & 3 deletions lms/djangoapps/certificates/generation_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Loading