From d587a39efb9c2f7eb37c855dee3b38d10595b67e Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Fri, 31 Jul 2026 19:45:38 +0000 Subject: [PATCH] feat: context population of activation emails via filters --- .../djangoapps/student/tests/test_filters.py | 199 +++++++++++++++++- common/djangoapps/student/views/management.py | 15 +- 2 files changed, 209 insertions(+), 5 deletions(-) diff --git a/common/djangoapps/student/tests/test_filters.py b/common/djangoapps/student/tests/test_filters.py index bf79ed7ae402..5303421ee61f 100644 --- a/common/djangoapps/student/tests/test_filters.py +++ b/common/djangoapps/student/tests/test_filters.py @@ -5,14 +5,16 @@ from django.http import HttpResponse from django.test import override_settings from django.urls import reverse +from django.utils.http import urlencode from openedx_filters import PipelineStep -from openedx_filters.learning.filters import DashboardRenderStarted, CourseEnrollmentStarted, CourseUnenrollmentStarted +from openedx_filters.learning.filters import CourseEnrollmentStarted, CourseUnenrollmentStarted, DashboardRenderStarted from rest_framework import status from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from common.djangoapps.student.models import CourseEnrollment, EnrollmentNotAllowed, UnenrollmentNotAllowed +from common.djangoapps.student.models import CourseEnrollment, EnrollmentNotAllowed, Registration, UnenrollmentNotAllowed from common.djangoapps.student.tests.factories import UserFactory, UserProfileFactory +from common.djangoapps.student.views.management import compose_activation_email from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -111,6 +113,37 @@ def run_filter(self, context, template_name): # pylint: disable=arguments-diffe ) +class TestActivationEmailComposedPipelineStep(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, user, message_context): # pylint: disable=arguments-differ + """Pipeline step that stamps a marker onto the activation email context.""" + message_context["is_enterprise_learner"] = True + return {"user": user, "message_context": message_context} + + +class TestActivationRedirectPipelineStep(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, user, redirect_url): # pylint: disable=arguments-differ + """Pipeline step that clears the post-activation redirect.""" + return {"user": user, "redirect_url": ""} + + +class TestActivationRedirectOverridePipelineStep(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, user, redirect_url): # pylint: disable=arguments-differ + """Pipeline step that overrides the post-activation redirect to a custom URL.""" + return {"user": user, "redirect_url": "https://custom-post-activation-page.com"} + + @skip_unless_lms class EnrollmentFiltersTest(ModuleStoreTestCase): """ @@ -464,3 +497,165 @@ def test_dashboard_render_without_filter_config(self): self.assertContains(response, self.first_course.id) self.assertContains(response, self.second_course.id) + + +@skip_unless_lms +class AccountActivationEmailFiltersTest(ModuleStoreTestCase): + """ + Tests for the Open edX Filters associated with composing the account activation email. + + This class guarantees that the following filter is triggered when the activation email + is composed: + - AccountActivationEmailComposed + """ + + def setUp(self): # pylint: disable=arguments-differ + super().setUp() + self.user = UserFactory() + self.registration = Registration() + self.registration.register(self.user) + self.registration.save() + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.account.activation.email.compose.v1": { + "pipeline": [ + "common.djangoapps.student.tests.test_filters.TestActivationEmailComposedPipelineStep", + ], + "fail_silently": False, + }, + }, + ) + def test_activation_email_composed_filter_executed(self): + """ + Test whether the activation email composed filter is triggered before the + activation email message context is finalized. + + Expected result: + - AccountActivationEmailComposed is triggered and executes + TestActivationEmailComposedPipelineStep. + - The composed message's context contains the pipeline step's modification. + """ + message = compose_activation_email(self.user, self.registration) + + self.assertTrue(message.context["is_enterprise_learner"]) + + @override_settings(OPEN_EDX_FILTERS_CONFIG={}) + def test_activation_email_composed_without_filter_config(self): + """ + Test that compose_activation_email succeeds with no pipeline steps configured. + + Expected result: + - AccountActivationEmailComposed executes a noop (empty pipeline). + - No 'is_enterprise_learner' key is injected into the message context. + """ + message = compose_activation_email(self.user, self.registration) + + self.assertNotIn("is_enterprise_learner", message.context) + + +@skip_unless_lms +class AccountActivationCompletedFiltersTest(ModuleStoreTestCase): + """ + Tests for the Open edX Filters associated with the post-activation redirect. + + This class guarantees that the following filter is triggered when a user's account + is activated: + - AccountActivationCompleted + """ + + def setUp(self): # pylint: disable=arguments-differ + super().setUp() + self.password = self.TEST_PASSWORD + self.user = UserFactory(is_active=False, password=self.password) + self.registration = Registration() + self.registration.register(self.user) + self.registration.save() + + def _login(self): + """Activate, authenticate, then restore the original active state.""" + is_active = self.user.is_active + self.user.is_active = True + self.user.save() + self.client.login(username=self.user.username, password=self.password) + self.user.is_active = is_active + self.user.save() + + def _activation_url(self, redirect_url): + base = reverse('activate', args=[self.registration.activation_key]) + return '{base}?{params}'.format(base=base, params=urlencode({'next': redirect_url})) + + @override_settings( + LOGIN_REDIRECT_WHITELIST=['localhost:1991'], + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.account.activation.completed.v1": { + "pipeline": [ + "common.djangoapps.student.tests.test_filters.TestActivationRedirectPipelineStep", + ], + "fail_silently": False, + }, + }, + ) + def test_activation_completed_filter_clears_redirect(self): + """ + Test that a pipeline step can clear the post-activation redirect. + + Expected result: + - AccountActivationCompleted is triggered and executes + TestActivationRedirectPipelineStep. + - Despite a valid `next` URL being provided, the user is redirected to the + dashboard because the pipeline step cleared redirect_url. + """ + self._login() + redirect_url = 'http://localhost:1991/pied-piper/learn' + + response = self.client.get(self._activation_url(redirect_url), HTTP_ACCEPT='*/*') + + self.assertEqual(status.HTTP_302_FOUND, response.status_code) + self.assertTrue(response.url.endswith(reverse('dashboard'))) + + @override_settings( + LOGIN_REDIRECT_WHITELIST=['localhost:1991'], + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.account.activation.completed.v1": { + "pipeline": [ + "common.djangoapps.student.tests.test_filters.TestActivationRedirectOverridePipelineStep", + ], + "fail_silently": False, + }, + }, + ) + def test_activation_completed_filter_overrides_redirect(self): + """ + Test that a pipeline step can override the post-activation redirect destination. + + Expected result: + - AccountActivationCompleted is triggered and executes + TestActivationRedirectOverridePipelineStep. + - The user is redirected to the URL provided by the pipeline step, not the + original `next` URL. + """ + self._login() + redirect_url = 'http://localhost:1991/pied-piper/learn' + + response = self.client.get(self._activation_url(redirect_url), HTTP_ACCEPT='*/*') + + self.assertEqual(status.HTTP_302_FOUND, response.status_code) + self.assertEqual('https://custom-post-activation-page.com', response.url) + + @override_settings(LOGIN_REDIRECT_WHITELIST=['localhost:1991'], OPEN_EDX_FILTERS_CONFIG={}) + def test_activation_completed_without_filter_config(self): + """ + Test that activation redirect behaves as a noop with no pipeline steps configured. + + Expected result: + - AccountActivationCompleted executes a noop (empty pipeline). + - The user is redirected to the originally requested `next` URL. + """ + self._login() + redirect_url = 'http://localhost:1991/pied-piper/learn' + + response = self.client.get(self._activation_url(redirect_url), HTTP_ACCEPT='*/*') + + self.assertEqual(status.HTTP_302_FOUND, response.status_code) + self.assertEqual(redirect_url, response.url) diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index 412781f73cce..6fbbdeb1fba6 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -36,6 +36,7 @@ # Note that this lives in LMS, so this dependency should be refactored. from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey +from openedx_filters.learning.filters import AccountActivationCompleted, AccountActivationEmailComposed from rest_framework.decorators import api_view, authentication_classes, permission_classes from rest_framework.permissions import IsAuthenticated @@ -98,7 +99,6 @@ from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.features.course_experience.url_helpers import make_learning_mfe_courseware_url from openedx.features.discounts.applicability import FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG -from openedx.features.enterprise_support.utils import is_enterprise_learner from common.djangoapps.util.db import outer_atomic from common.djangoapps.util.json_request import JsonResponse from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order @@ -222,7 +222,6 @@ def compose_activation_email( message_context = generate_activation_email_context(user, user_registration) message_context.update({ 'confirm_activation_link': _get_activation_confirmation_link(message_context['key'], redirect_url), - 'is_enterprise_learner': is_enterprise_learner(user), 'is_first_purchase_discount_overridden': FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG.is_enabled(), 'route_enabled': route_enabled, 'routed_user': user.username, @@ -231,6 +230,11 @@ def compose_activation_email( 'registration_flow': registration_flow, 'show_auto_generated_username': show_auto_generated_username(user.username), }) + # .. filter_implemented_name: AccountActivationEmailComposed + # .. filter_type: org.openedx.learning.account.activation.email.compose.v1 + __, message_context = AccountActivationEmailComposed.run_filter( + user=user, message_context=message_context, + ) if route_enabled: dest_addr = settings.FEATURES['REROUTE_ACTIVATION_EMAIL'] @@ -695,7 +699,12 @@ def activate_account(request, key): url_path = '/login?{}'.format(urllib.parse.urlencode(params)) return redirect(settings.AUTHN_MICROFRONTEND_URL + url_path) - response = redirect(redirect_url) if redirect_url and is_enterprise_learner(request.user) else redirect('dashboard') + # .. filter_implemented_name: AccountActivationCompleted + # .. filter_type: org.openedx.learning.account.activation.completed.v1 + __, redirect_url = AccountActivationCompleted.run_filter( + user=request.user, redirect_url=redirect_url or "", + ) + response = redirect(redirect_url) if redirect_url else redirect('dashboard') if show_account_activation_popup: response.delete_cookie( settings.SHOW_ACTIVATE_CTA_POPUP_COOKIE_NAME,