diff --git a/RELEASE.rst b/RELEASE.rst index cdd32f7d60..330fc6a03d 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,11 @@ Release Notes ============= +Version 0.66.11 +--------------- + +- feat: Welcome to learn email on new account setup (#3189) + Version 0.66.10 (Released May 07, 2026) --------------- diff --git a/authentication/api.py b/authentication/api.py index 7ac1ca07bc..a8f73ea18f 100644 --- a/authentication/api.py +++ b/authentication/api.py @@ -33,9 +33,17 @@ def create_user(username, email, profile_data=None, user_extra=None): defaults.update({"username": username}) with transaction.atomic(): - user, _ = User.objects.get_or_create(email=email, defaults=defaults) + user, created = User.objects.get_or_create(email=email, defaults=defaults) profile_api.ensure_profile(user, profile_data=profile_data) + if created: + transaction.on_commit( + lambda: user_created_actions( + user=user, + is_new=True, + details=profile_data or {}, + ) + ) return user diff --git a/authentication/api_test.py b/authentication/api_test.py index aef0f9fafe..29ded90065 100644 --- a/authentication/api_test.py +++ b/authentication/api_test.py @@ -37,6 +37,35 @@ def test_create_user(profile_data): assert user.profile.name is None +@pytest.mark.django_db(transaction=True) +def test_create_user_triggers_plugins_for_new_users(mocker): + """create_user should trigger user_created plugins for brand new users.""" + mock_pm = mocker.Mock() + mocker.patch("authentication.api.get_plugin_manager", return_value=mock_pm) + + user = api.create_user("new-user", "new@localhost", {"name": "New User"}) + + mock_pm.hook.user_created.assert_called_once_with( + user=user, + user_data={"profile": {"name": "New User"}}, + ) + + +@pytest.mark.django_db(transaction=True) +def test_create_user_does_not_retrigger_plugins_for_existing_users(mocker): + """create_user should not trigger user_created plugins for existing users.""" + user = UserFactory.create(email="existing@localhost") + mock_pm = mocker.Mock() + mocker.patch("authentication.api.get_plugin_manager", return_value=mock_pm) + + resolved = api.create_user( + "another-username", user.email, {"name": "Existing User"} + ) + + assert resolved.id == user.id + mock_pm.hook.user_created.assert_not_called() + + @pytest.mark.parametrize( "mock_method", ["profiles.api.ensure_profile"], diff --git a/main/middleware/apisix_user_test.py b/main/middleware/apisix_user_test.py index 50cdbdc31c..aed867a296 100644 --- a/main/middleware/apisix_user_test.py +++ b/main/middleware/apisix_user_test.py @@ -84,6 +84,51 @@ def test_get_request_no_posthog_key(mocker, mock_login, settings): mock_posthog_cls.assert_not_called() +@pytest.mark.django_db(transaction=True) +def test_get_request_queues_welcome_email_for_new_sso_user(mocker, mock_login): + """New APISIX users should queue welcome email once.""" + close_old_connections() + mock_delay = mocker.patch("profiles.plugins.send_welcome_email.delay") + mock_request = mocker.Mock( + META={ + "HTTP_X_USERINFO": b64encode(json.dumps(apisix_user_info).encode()), + }, + user=AnonymousUser(), + ) + apisix_middleware = ApisixUserMiddleware(mocker.Mock()) + apisix_middleware.process_request(mock_request) + + user = User.objects.get(email=apisix_user_info["email"]) + mock_login.assert_called_once() + mock_delay.assert_called_once_with(user.id) + + +@pytest.mark.django_db(transaction=True) +def test_get_request_does_not_queue_welcome_email_for_existing_sso_user( + mocker, mock_login +): + """Existing APISIX users should not queue welcome email again.""" + close_old_connections() + existing_user = UserFactory.create( + email=apisix_user_info["email"], + username=apisix_user_info["preferred_username"], + global_id=apisix_user_info["sub"], + ) + mock_delay = mocker.patch("profiles.plugins.send_welcome_email.delay") + mock_request = mocker.Mock( + META={ + "HTTP_X_USERINFO": b64encode(json.dumps(apisix_user_info).encode()), + }, + user=AnonymousUser(), + ) + apisix_middleware = ApisixUserMiddleware(mocker.Mock()) + apisix_middleware.process_request(mock_request) + + existing_user.refresh_from_db() + mock_login.assert_called_once() + mock_delay.assert_not_called() + + @pytest.mark.django_db(transaction=True) def test_get_request_existing_user_no_globalid(mocker, mock_login): """Test that a valid request updates existing user with same email, no global_id.""" diff --git a/main/settings.py b/main/settings.py index ee45d02bce..81005c45b3 100644 --- a/main/settings.py +++ b/main/settings.py @@ -35,7 +35,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.66.10" +VERSION = "0.66.11" log = logging.getLogger() diff --git a/main/settings_pluggy.py b/main/settings_pluggy.py index da6aad907f..9b121b402d 100644 --- a/main/settings_pluggy.py +++ b/main/settings_pluggy.py @@ -2,7 +2,7 @@ MITOL_AUTHENTICATION_PLUGINS = get_string( "MITOL_AUTHENTICATION_PLUGINS", - "learning_resources.plugins.FavoritesListPlugin,profiles.plugins.CreateProfilePlugin", + "learning_resources.plugins.FavoritesListPlugin,profiles.plugins.CreateProfilePlugin,profiles.plugins.WelcomeEmailPlugin", ) MITOL_LEARNING_RESOURCES_PLUGINS = get_string( "MITOL_LEARNING_RESOURCES_PLUGINS", diff --git a/main/templates/email/welcome_email.html b/main/templates/email/welcome_email.html new file mode 100644 index 0000000000..a606ecf97b --- /dev/null +++ b/main/templates/email/welcome_email.html @@ -0,0 +1,128 @@ +{% extends "email/email_base.html" %} + +{% block content %} + + +

+ Hi + {{ display_name }}, +

+ + Welcome to MIT Learn. Your account is ready. + +

+ The MIT Learn platform brings together courses, programs and learning + materials from across MIT. With advanced search and AskTIM's AI-powered + guidance, quickly discover the most relevant content for your learning + goals. +

+

+ With your account, you can make MIT Learn your own: +

+ +

+ Use your account to save what matters, follow your interests, and pick up + your learning where you left off. +

+ + + + +
+ Go To Your Dashboard +
+
+

+ The MIT Learn Team +

+

+

+ MIT Learn • 77 Massachusetts Avenue • + Cambridge, MA 02139 • USA +

+ + +{% endblock %} + +{% block footer %}{% endblock %} +{% block footer-address %}{% endblock %} diff --git a/profiles/plugins.py b/profiles/plugins.py index 7faccab4e3..9286f3e28a 100644 --- a/profiles/plugins.py +++ b/profiles/plugins.py @@ -3,6 +3,7 @@ from django.apps import apps from profiles.api import ensure_profile +from profiles.tasks import send_welcome_email class CreateProfilePlugin: @@ -19,3 +20,18 @@ def user_created(self, user, user_data): """ profile_data = user_data.get("profile", {}) ensure_profile(user, profile_data) + + +class WelcomeEmailPlugin: + hookimpl = apps.get_app_config("authentication").hookimpl + + @hookimpl + def user_created(self, user, user_data): # noqa: ARG002 + """ + Send a welcome email when a new user account is created. + + Args: + user(User): the user that was created + user_data(dict): the user data + """ + send_welcome_email.delay(user.id) diff --git a/profiles/plugins_test.py b/profiles/plugins_test.py new file mode 100644 index 0000000000..99a9171652 --- /dev/null +++ b/profiles/plugins_test.py @@ -0,0 +1,17 @@ +"""Tests for profiles plugins.""" + +import pytest + +from main.factories import UserFactory +from profiles.plugins import WelcomeEmailPlugin + + +@pytest.mark.django_db +def test_welcome_email_plugin_user_created(mocker): + """WelcomeEmailPlugin should queue welcome email task on user creation.""" + user = UserFactory.create(email="new.user@example.com", first_name="New") + mocked_delay = mocker.patch("profiles.plugins.send_welcome_email.delay") + + WelcomeEmailPlugin().user_created(user, user_data={}) + + mocked_delay.assert_called_once_with(user.id) diff --git a/profiles/tasks.py b/profiles/tasks.py new file mode 100644 index 0000000000..31764e8b0c --- /dev/null +++ b/profiles/tasks.py @@ -0,0 +1,41 @@ +"""Tasks for profiles.""" + +import logging + +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist + +from main.celery import app +from profiles.utils import send_template_email + +log = logging.getLogger(__name__) +User = get_user_model() + + +@app.task +def send_welcome_email(user_id): + """ + Send a welcome email to a user by id. + """ + user = User.objects.filter(id=user_id).first() + if not user: + log.warning("User %s not found for welcome email", user_id) + return + if not user.email: + log.warning("User %s has blank email, skipping welcome email", user_id) + return + + try: + profile_name = user.profile.name + except ObjectDoesNotExist: + profile_name = None + full_name = " ".join( + part for part in [user.first_name, user.last_name] if part + ).strip() + display_name = profile_name or full_name or user.username or "there" + send_template_email( + [user.email], + "MIT Learn - Welcome to MIT Learn", + "email/welcome_email.html", + context={"display_name": display_name}, + ) diff --git a/profiles/tasks_test.py b/profiles/tasks_test.py new file mode 100644 index 0000000000..9f7f01f425 --- /dev/null +++ b/profiles/tasks_test.py @@ -0,0 +1,112 @@ +"""Tests for profile tasks.""" + +import pytest + +from main.factories import UserFactory +from profiles.tasks import send_welcome_email + + +@pytest.mark.django_db +def test_send_welcome_email_sends_template_email(mocker): + """send_welcome_email should send rendered welcome template for valid user.""" + user = UserFactory.create(email="new.user@example.com", first_name="New") + user.profile.name = "Full Name" + user.profile.save() + mocked_send = mocker.patch("profiles.tasks.send_template_email") + + send_welcome_email(user.id) + + mocked_send.assert_called_once_with( + ["new.user@example.com"], + "MIT Learn - Welcome to MIT Learn", + "email/welcome_email.html", + context={"display_name": "Full Name"}, + ) + + +@pytest.mark.django_db +def test_send_welcome_email_missing_user(mocker): + """send_welcome_email should no-op when user does not exist.""" + mocked_send = mocker.patch("profiles.tasks.send_template_email") + + send_welcome_email(999999) + + mocked_send.assert_not_called() + + +@pytest.mark.django_db +def test_send_welcome_email_blank_email(mocker): + """send_welcome_email should no-op when user has blank email.""" + user = UserFactory.create(email="") + mocked_send = mocker.patch("profiles.tasks.send_template_email") + + send_welcome_email(user.id) + + mocked_send.assert_not_called() + + +@pytest.mark.django_db +def test_send_welcome_email_uses_full_name_when_profile_name_missing(mocker): + """Falls back to first+last name if profile.name is missing.""" + user = UserFactory.create( + email="full.name@example.com", + first_name="Full", + last_name="Name", + ) + user.profile.name = None + user.profile.save() + mocked_send = mocker.patch("profiles.tasks.send_template_email") + + send_welcome_email(user.id) + + mocked_send.assert_called_once_with( + ["full.name@example.com"], + "MIT Learn - Welcome to MIT Learn", + "email/welcome_email.html", + context={"display_name": "Full Name"}, + ) + + +@pytest.mark.django_db +def test_send_welcome_email_uses_username_when_names_missing(mocker): + """Falls back to username when profile/full name are not available.""" + user = UserFactory.create( + email="username.only@example.com", + first_name="", + last_name="", + username="username-only", + ) + user.profile.name = None + user.profile.save() + mocked_send = mocker.patch("profiles.tasks.send_template_email") + + send_welcome_email(user.id) + + mocked_send.assert_called_once_with( + ["username.only@example.com"], + "MIT Learn - Welcome to MIT Learn", + "email/welcome_email.html", + context={"display_name": "username-only"}, + ) + + +@pytest.mark.django_db +def test_send_welcome_email_handles_missing_profile_relation(mocker): + """Falls back cleanly when the reverse profile relation is missing.""" + user = UserFactory.create( + email="missing.profile@example.com", + first_name="", + last_name="", + username="profile-missing", + ) + user.profile.delete() + mocked_send = mocker.patch("profiles.tasks.send_template_email") + + send_welcome_email(user.id) + + mocked_send.assert_called_once_with( + ["missing.profile@example.com"], + "MIT Learn - Welcome to MIT Learn", + "email/welcome_email.html", + context={"display_name": "profile-missing"}, + )