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
6 changes: 6 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
Release Notes
=============

Version 1.162.5
---------------

- Backfill missing LegalAddress/UserProfile for migrated users; guard should_skip_onboarding (#3845)
- refactor: pass the Learn Fastly service ID at the call sites (#3804)

Version 1.162.4 (Released August 12, 2026)
---------------

Expand Down
3 changes: 2 additions & 1 deletion authentication/api_gateway/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
USER_MSG_TYPE_PROFILE_CREATED,
)
from main.utils import encode_json_cookie_value, is_success_response
from users.models import UserProfile

User = get_user_model()

Expand Down Expand Up @@ -135,7 +136,7 @@ def get(
params = urlencode({"next": redirect_url})
redirect_url = f"{settings.MITXONLINE_NEW_USER_LOGIN_URL}?{params}"

profile = user.user_profile
profile, _ = UserProfile.objects.get_or_create(user=user)
profile.completed_onboarding = True
profile.save()
return redirect(redirect_url)
Expand Down
21 changes: 21 additions & 0 deletions authentication/api_gateway/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,27 @@ def test_custom_login_view_authenticated_user_with_onboarding(settings, client):
assert user.user_profile.completed_onboarding is True


def test_custom_login_view_with_no_user_profile(settings, client):
"""A user with no UserProfile row at all (e.g. one created by the
historical migrate_edx_data bulk_create bug) must not crash logging in -
the view must create the missing UserProfile rather than accessing
user.user_profile directly
"""
settings.MITXONLINE_NEW_USER_LOGIN_URL = "/create-profile"

user = UserFactory.create()
UserProfile.objects.filter(user=user).delete()

client.force_login(user)

qs = {"next": "/dashboard"}
response = client.get(f"{reverse('gateway-login')}?{urlencode(qs)}")

assert response.status_code == 302
assert response.url == "/create-profile?next=%2Fdashboard"
assert UserProfile.objects.get(user=user).completed_onboarding is True


@pytest.mark.parametrize(
"next_url", ["/dashboard", "http://openedx.odl.local/courses/abc"]
)
Expand Down
7 changes: 6 additions & 1 deletion cms/signals.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging

from django.conf import settings
from django.db import transaction
from wagtail.signals import page_published

Expand Down Expand Up @@ -56,7 +57,11 @@ def purge_fastly_cache_on_publish(sender, **kwargs): # noqa: ARG001
logger.info(
"Scheduling Fastly surrogate key purge on page publish: %s", surrogate_key
)
transaction.on_commit(lambda: queue_fastly_surrogate_key_purge.delay(surrogate_key))
transaction.on_commit(
lambda: queue_fastly_surrogate_key_purge.delay(
surrogate_key, settings.MIT_LEARN_FASTLY_SERVICE_ID
)
)


page_published.connect(flex_pricing_field_check)
Expand Down
21 changes: 17 additions & 4 deletions cms/signals_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,21 @@

pytestmark = pytest.mark.django_db

LEARN_SERVICE_ID = "test-learn-service-id"


@pytest.fixture
def learn_service_id(settings):
"""Point the purge at a known MIT Learn Fastly service."""
settings.MIT_LEARN_FASTLY_SERVICE_ID = LEARN_SERVICE_ID
return settings.MIT_LEARN_FASTLY_SERVICE_ID


@patch("cms.signals.transaction.on_commit", side_effect=lambda callback: callback())
@patch("cms.tasks.queue_fastly_surrogate_key_purge.delay")
def test_purge_fastly_cache_on_publish_course_page(mock_purge_delay, mock_on_commit):
def test_purge_fastly_cache_on_publish_course_page(
mock_purge_delay, mock_on_commit, learn_service_id
):
"""Publishing a CoursePage purges the key for its course."""
course_page = CoursePageFactory.create()
mock_purge_delay.reset_mock()
Expand All @@ -26,13 +37,15 @@ def test_purge_fastly_cache_on_publish_course_page(mock_purge_delay, mock_on_com
course_page.save_revision().publish()

mock_purge_delay.assert_called_once_with(
f"mitxonline:course:{course_page.course.readable_id}"
f"mitxonline:course:{course_page.course.readable_id}", learn_service_id
)


@patch("cms.signals.transaction.on_commit", side_effect=lambda callback: callback())
@patch("cms.tasks.queue_fastly_surrogate_key_purge.delay")
def test_purge_fastly_cache_on_publish_program_page(mock_purge_delay, mock_on_commit):
def test_purge_fastly_cache_on_publish_program_page(
mock_purge_delay, mock_on_commit, learn_service_id
):
"""Publishing a ProgramPage purges the key for its program."""
program_page = ProgramPageFactory.create()
mock_purge_delay.reset_mock()
Expand All @@ -41,7 +54,7 @@ def test_purge_fastly_cache_on_publish_program_page(mock_purge_delay, mock_on_co
program_page.save_revision().publish()

mock_purge_delay.assert_called_once_with(
f"mitxonline:program:{program_page.program.readable_id}"
f"mitxonline:program:{program_page.program.readable_id}", learn_service_id
)


Expand Down
9 changes: 7 additions & 2 deletions cms/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,16 +121,21 @@ def queue_fastly_surrogate_key_purge(surrogate_key, service_id=None):

service_id = service_id or settings.MIT_LEARN_FASTLY_SERVICE_ID

# Logged at error level, not warning: a missing setting disables cache
# invalidation entirely, and Sentry only raises an issue from ERROR and above
# (main.sentry passes no event_level, so it keeps the sentry_sdk default).
# A warning here is a breadcrumb, which is how the unset service ID went
# unnoticed until it was found by reading the code.
if not service_id:
logger.warning(
logger.error(
"No Fastly service ID given; skipping surrogate key purge for %s. "
"Is MIT_LEARN_FASTLY_SERVICE_ID set?",
surrogate_key,
)
return False

if not settings.FASTLY_AUTH_TOKEN:
logger.warning(
logger.error(
"FASTLY_AUTH_TOKEN is not set; skipping surrogate key purge for %s",
surrogate_key,
)
Expand Down
16 changes: 13 additions & 3 deletions cms/tasks_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for cms.tasks"""

import logging

import pytest
import responses

Expand Down Expand Up @@ -71,25 +73,33 @@ def test_queue_fastly_surrogate_key_purge_falls_back_to_settings(fastly_settings


@responses.activate
def test_queue_fastly_surrogate_key_purge_skips_without_service_id(fastly_settings):
def test_queue_fastly_surrogate_key_purge_skips_without_service_id(
fastly_settings, caplog
):
"""
With no service ID passed and none configured, the purge is skipped.

It must skip rather than raise or request `/service/None/purge/...`.
It must skip rather than raise or request `/service/None/purge/...`, and it
must say so at error level -- an unconfigured service ID disables cache
invalidation, and only ERROR and above reaches Sentry as an issue.
"""
fastly_settings.MIT_LEARN_FASTLY_SERVICE_ID = None

assert queue_fastly_surrogate_key_purge(SURROGATE_KEY) is False
assert not responses.calls
assert [record.levelno for record in caplog.records] == [logging.ERROR]


@responses.activate
def test_queue_fastly_surrogate_key_purge_skips_without_auth_token(fastly_settings):
def test_queue_fastly_surrogate_key_purge_skips_without_auth_token(
fastly_settings, caplog
):
"""A missing auth token skips the purge rather than sending it unauthenticated."""
fastly_settings.FASTLY_AUTH_TOKEN = None

assert queue_fastly_surrogate_key_purge(SURROGATE_KEY, LEARN_SERVICE_ID) is False
assert not responses.calls
assert [record.levelno for record in caplog.records] == [logging.ERROR]


@responses.activate
Expand Down
104 changes: 103 additions & 1 deletion courses/management/commands/migrate_edx_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,100 @@ def _migrate_users(self, conn, options):

self.stdout.write(self.style.SUCCESS(f"{user_creation_count} users created"))

def _repair_migrated_user_profiles(self, conn, options):
"""
Backfill LegalAddress/UserProfile rows for users left without them by
a historical bug in _bulk_create_users: bulk_create(ignore_conflicts=True)
never populates .pk on the objects passed into it, so every call here
used to build id_row_lookup keyed on user.id=None and then filter
User.objects.filter(id__in=[None, ...]), which always matches zero
rows - LegalAddress/UserProfile silently never got created for any
user who went through this command, regardless of the row data
Trino actually had for them.

This re-scans the same edX table used by _migrate_users, but only
acts on users already missing one of these rows - real, already-saved
User objects with real ids, so the existing bulk-create helpers work
correctly here the same way they already do for the enrollments
migration's opportunistic repair path.
"""
limit = options.get("limit")
batch_size = options.get("batch_size", 1000)
dry_run = options.get("dry_run")

affected_users = list(
User.objects.filter(
Q(legal_address__isnull=True) | Q(user_profile__isnull=True)
).only("id", "email")
)
if limit is not None:
affected_users = affected_users[:limit]
affected_by_email = {user.email: user for user in affected_users if user.email}

self.stdout.write(
f"{len(affected_by_email)} users found missing "
f"LegalAddress and/or UserProfile"
)

cur = conn.cursor()
cur.execute("SELECT * FROM edxorg_to_mitxonline_users")
columns = [desc[0] for desc in cur.description]

# e.g. {"Male": "m", "Female": "f"}
GENDER_MAP = {label: code for code, label in GENDER_CHOICES}

repaired_emails = set()
while True:
results = cur.fetchmany(batch_size)
if not results:
break

rows = [dict(zip(columns, r)) for r in results]

matched_users = []
row_lookup_by_id = {}
for row in rows:
email = row.get("user_email")
user = affected_by_email.get(email)
if user is None or email in repaired_emails:
continue
matched_users.append(user)
row_lookup_by_id[user.id] = row
repaired_emails.add(email)

if not matched_users:
continue

if dry_run:
continue

self._bulk_create_legal_addresses(
matched_users, row_lookup_by_id, batch_size
)
self._bulk_create_user_profiles(
matched_users, row_lookup_by_id, batch_size, GENDER_MAP
)

unmatched = len(affected_by_email) - len(repaired_emails)
if dry_run:
self.stdout.write(
self.style.WARNING(
f"[DRY RUN] Would repair {len(repaired_emails)} users"
)
)
else:
self.stdout.write(
self.style.SUCCESS(f"Repaired {len(repaired_emails)} users")
)
if unmatched:
self.stdout.write(
self.style.WARNING(
f"{unmatched} affected users had no matching row in "
f"edxorg_to_mitxonline_users - still missing "
f"LegalAddress/UserProfile, needs manual review"
)
)

@staticmethod
def _bulk_create_enrollments(
rows,
Expand Down Expand Up @@ -1048,9 +1142,10 @@ def add_arguments(self, parser) -> None:
"program_certificates",
"entitlements",
"future_enrollments",
"repair_migrated_profiles",
],
default="course_runs",
help="Choose which migration to run: course_runs, users, course_certificates, program_certificates, entitlements, future_enrollments (default: course_runs)",
help="Choose which migration to run: course_runs, users, course_certificates, program_certificates, entitlements, future_enrollments, repair_migrated_profiles (default: course_runs)",
)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument(
Expand All @@ -1072,6 +1167,13 @@ def handle(self, *args, **options): # pylint: disable=unused-argument # noqa: A
self.stdout.write("Migrating the edX users ...")
self._migrate_users(conn, options)

if migrate_type == "repair_migrated_profiles":
self.stdout.write(
"Repairing LegalAddress/UserProfile rows missed by a "
"historical bug in the users migration ..."
)
self._repair_migrated_user_profiles(conn, options)

if migrate_type == "course_certificates":
self.stdout.write(
"Migrating the edX course enrollments, grades and certificates ..."
Expand Down
Loading
Loading