From 1297d9280e3b89f5f973db2a254d9339c08f7d22 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Thu, 17 Sep 2026 15:51:03 -0700 Subject: [PATCH] feat: Add plugin hook for activation email context, and remove enterprise context A new OpenEdX filter AccountActivationEmailContextGenerated is called (in place of custom enterprise logic) to allow plugins more flexibility in customizing the account activation email via theme-provided custom email templates. Relatedly, this removes in-platform injection of the `is_enterprise_learner` key into the activation email context. This can now be accomplished via plugins which implement a pipeline step for the newly added filter. Upstreamed from edx/edx-platform#425. ENT-11816 --- .../djangoapps/student/tests/test_filters.py | 75 ++++++++++++++++++- common/djangoapps/student/views/management.py | 8 +- .../djangoapps/user_authn/tests/test_tasks.py | 1 - pyproject.toml | 4 +- requirements/edx/base.txt | 4 +- requirements/edx/development.txt | 4 +- uv.lock | 14 ++-- 7 files changed, 93 insertions(+), 17 deletions(-) diff --git a/common/djangoapps/student/tests/test_filters.py b/common/djangoapps/student/tests/test_filters.py index 759c953dd65d..e24dc944bb05 100644 --- a/common/djangoapps/student/tests/test_filters.py +++ b/common/djangoapps/student/tests/test_filters.py @@ -9,8 +9,14 @@ from openedx_filters.learning.filters import CourseEnrollmentStarted, CourseUnenrollmentStarted, DashboardRenderStarted from rest_framework import status -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 from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -111,6 +117,18 @@ def run_filter(self, context, template_name): # pylint: disable=arguments-diffe ) +class ActivationEmailWaldoEnricher(PipelineStep): + """ + Test pipeline step for the AccountActivationEmailContextGenerated filter + which adds a `show_waldo` context key. + """ + + def run_filter(self, user, message_context): # pylint: disable=arguments-differ + """Pipeline step that stamps a fake show_waldo flag onto the activation email context.""" + message_context["show_waldo"] = True + return {"user": user, "message_context": message_context} + + @skip_unless_lms class EnrollmentFiltersTest(ModuleStoreTestCase): """ @@ -464,3 +482,58 @@ 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 the account activation email context. + + This class guarantees that the following filter is triggered when the activation email + context is generated: + - AccountActivationEmailContextGenerated + """ + + 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.authentication.account_activation.email.context.generated.v1": { + "pipeline": [ + "common.djangoapps.student.tests.test_filters.ActivationEmailWaldoEnricher", + ], + "fail_silently": False, + }, + }, + ) + def test_activation_email_context_generated_filter_executed(self): + """ + Test whether the activation email context filter is triggered before the + activation email message context is finalized. + + Expected result: + - AccountActivationEmailContextGenerated is triggered and executes + ActivationEmailWaldoEnricher. + - The composed message's context contains the pipeline step's modification. + """ + message = compose_activation_email(self.user, self.registration) + + assert message.context["show_waldo"] is True + + @override_settings(OPEN_EDX_FILTERS_CONFIG={}) + def test_activation_email_context_generated_without_filter_config(self): + """ + Test that compose_activation_email succeeds with no pipeline steps configured. + + Expected result: + - AccountActivationEmailContextGenerated executes a noop (empty pipeline). + - No 'show_waldo' key is injected into the message context. + """ + message = compose_activation_email(self.user, self.registration) + + assert "show_waldo" not in message.context diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index bcce34ce8546..0e5af807b119 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -46,6 +46,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.authentication.filters import AccountActivationEmailContextGenerated from rest_framework.decorators import api_view, authentication_classes, permission_classes from rest_framework.permissions import IsAuthenticated @@ -114,7 +115,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 xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order log = logging.getLogger("edx.student") @@ -236,7 +236,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, @@ -245,6 +244,11 @@ def compose_activation_email( 'registration_flow': registration_flow, 'show_auto_generated_username': show_auto_generated_username(user.username), }) + # .. filter_implemented_name: AccountActivationEmailContextGenerated + # .. filter_type: org.openedx.authentication.account_activation.email.context.generated.v1 + __, message_context = AccountActivationEmailContextGenerated.run_filter( + user=user, message_context=message_context, + ) if route_enabled: dest_addr = getattr(settings, 'REROUTE_ACTIVATION_EMAIL', False) diff --git a/openedx/core/djangoapps/user_authn/tests/test_tasks.py b/openedx/core/djangoapps/user_authn/tests/test_tasks.py index b4fa8456ef8f..f8848dbb1f5d 100644 --- a/openedx/core/djangoapps/user_authn/tests/test_tasks.py +++ b/openedx/core/djangoapps/user_authn/tests/test_tasks.py @@ -45,7 +45,6 @@ def test_ComposeEmail(self): assert self.msg.context['routed_user_email'] == self.student.email assert self.msg.context['routed_profile_name'] == '' assert self.msg.context['registration_flow'] is False - assert self.msg.context['is_enterprise_learner'] is False assert self.msg.context['is_first_purchase_discount_overridden'] is False @mock.patch('time.sleep', mock.Mock(return_value=None)) diff --git a/pyproject.toml b/pyproject.toml index 43d8bcf43350..2f775d6cd759 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -290,7 +290,7 @@ constraint-dependencies = [ "sphinx-autoapi<3.6.1", "setuptools<82", "astroid==4.0.4", - "edx-enterprise==8.11.0", + "edx-enterprise==8.14.0", "djangorestframework<3.18", ] [tool.edx_lint] @@ -407,7 +407,7 @@ uv_constraints = [ # The team that owns this package will manually bump this package rather than # having it pulled in automatically. This is to allow them to better control its # deployment and to do it in a process that works better for them. - "edx-enterprise==8.11.0", + "edx-enterprise==8.14.0", # Date: 2026-08-31 # DRF 3.18.0 changes many=True validation errors from a list to a dict keyed by # item index, which breaks the error response shape of several write endpoints. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 188f67917352..d83a8bf2d4de 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -480,7 +480,7 @@ edx-drf-extensions==10.9.0 # openedx-authz # openedx-core # openedx-platform -edx-enterprise==8.11.0 +edx-enterprise==8.14.0 # via openedx-platform edx-event-bus-kafka==6.1.0 # via openedx-platform @@ -845,7 +845,7 @@ openedx-events==11.2.0 # openedx-core # openedx-platform # ora2 -openedx-filters==3.13.0 +openedx-filters==3.14.0 # via # edx-enterprise # lti-consumer-xblock diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 2cb586ccb966..5ce243eb90a8 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -533,7 +533,7 @@ edx-drf-extensions==10.9.0 # openedx-authz # openedx-core # openedx-platform -edx-enterprise==8.11.0 +edx-enterprise==8.14.0 # via openedx-platform edx-event-bus-kafka==6.1.0 # via openedx-platform @@ -947,7 +947,7 @@ openedx-events==11.2.0 # openedx-core # openedx-platform # ora2 -openedx-filters==3.13.0 +openedx-filters==3.14.0 # via # edx-enterprise # lti-consumer-xblock diff --git a/uv.lock b/uv.lock index 37854583757c..90c1003510b7 100644 --- a/uv.lock +++ b/uv.lock @@ -20,7 +20,7 @@ constraints = [ { name = "django-oauth-toolkit", specifier = "==1.7.1" }, { name = "django-stubs", specifier = "<6" }, { name = "djangorestframework", specifier = "<3.18" }, - { name = "edx-enterprise", specifier = "==8.11.0" }, + { name = "edx-enterprise", specifier = "==8.14.0" }, { name = "elasticsearch", specifier = "==7.9.1" }, { name = "libsass", specifier = "==0.10.0" }, { name = "lxml", specifier = "==5.3.2" }, @@ -2024,7 +2024,7 @@ wheels = [ [[package]] name = "edx-enterprise" -version = "8.11.0" +version = "8.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bleach" }, @@ -2077,9 +2077,9 @@ dependencies = [ { name = "tincan" }, { name = "unicodecsv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/25/659c1ce1ee742721ddb8063da436bffed1a5b3891fbdf5e8bea5b289c601/edx_enterprise-8.11.0.tar.gz", hash = "sha256:e059a570463f01214555d431d467191e261a9f8d3ac5bce15161b4beefd48e9e", size = 5166614, upload-time = "2026-09-10T18:04:36.466Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/25/0c62f75b47792e3b0abe9ec7a123a1e16d58a6c81264ee893bb67233a32c/edx_enterprise-8.14.0.tar.gz", hash = "sha256:d3760635218cd3ec7b98d68a4d4e2d13a8a13b986bdb388e66e8223a63ecc7c1", size = 5167517, upload-time = "2026-09-17T20:56:02.762Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/d9/c867215a28e0ce0335710a574ec3da5632c9e299dba7cc82f1a646aac69d/edx_enterprise-8.11.0-py3-none-any.whl", hash = "sha256:49138decfadf5d5ee84854bc4e19840c74fde635ba8cc33b0d8eb57fb876c5f9", size = 5560621, upload-time = "2026-09-10T18:04:33.04Z" }, + { url = "https://files.pythonhosted.org/packages/ed/17/7b15595a2ea0b185d41bdb889504ec820eeb182fdb6e4e14b73bfddacf4d/edx_enterprise-8.14.0-py3-none-any.whl", hash = "sha256:fc1d9ba9a1ddf23506a5713eec8426175cc1cf7768a3844c3b76a0fe9b5811f6", size = 5561615, upload-time = "2026-09-17T20:55:59.604Z" }, ] [[package]] @@ -4429,7 +4429,7 @@ wheels = [ [[package]] name = "openedx-filters" -version = "3.13.0" +version = "3.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", version = "4.2.30", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-16-openedx-platform-django42'" }, @@ -4437,9 +4437,9 @@ dependencies = [ { name = "edx-opaque-keys" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d5/ff6693eac81f446f41a789fcc61b2e6d4feac12f4bf8008e9968bf09e67a/openedx_filters-3.13.0.tar.gz", hash = "sha256:4b41bc61fa789fe7cdd6095dc61f12f15492881e9023ccf6606262b7cc56e57d", size = 52411, upload-time = "2026-09-16T17:47:19.519Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/4a/7b1a7ec62978e24fc93327fc816378b63c3526776531187d0e52bcba09e8/openedx_filters-3.14.0.tar.gz", hash = "sha256:76f0260932aad1a3ccbb8c396b0e0ecb48a6f7045b7e5c7811efcbeb57f95310", size = 52864, upload-time = "2026-09-17T19:28:11.524Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/00/41dd3650ca52251844c3f7a48699ca66150e4a436b27fac03e6e7cf7b200/openedx_filters-3.13.0-py2.py3-none-any.whl", hash = "sha256:2992f607fb714d8d313b399a8b244d0510deb157717a74446e2cff01429f68af", size = 51236, upload-time = "2026-09-16T17:47:18.452Z" }, + { url = "https://files.pythonhosted.org/packages/a7/39/d313ab4fdddb6b51040f2c1a20e1bf2092b542d4c74ad0eaa6f797bc69e9/openedx_filters-3.14.0-py2.py3-none-any.whl", hash = "sha256:829eb5e57dbda6372832e72c665e141d5efa60dacacb23877239d6a1da33f1ba", size = 51670, upload-time = "2026-09-17T19:28:10.542Z" }, ] [[package]]