Backfill missing LegalAddress/UserProfile for migrated users; guard should_skip_onboarding - #3845
Conversation
…rd should_skip_onboarding migrate_edx_data's bulk_create(ignore_conflicts=True) bug (fixed separately) meant every user created by the "users" migration type ended up with no LegalAddress or UserProfile row at all - id_row_lookup was keyed on user.id=None for every newly created user, so User.objects.filter(id__in=[None]) always matched zero rows and the two bulk-create helpers silently created nothing. Add a repair_migrated_profiles migration type that re-scans the same edX table, but only acts on users already missing one of these rows - real, already-saved User objects with real ids, so the existing _bulk_create_legal_addresses/_bulk_create_user_profiles helpers work correctly the same way they already do for the enrollments migration's opportunistic repair path. Also guard User.should_skip_onboarding against a missing UserProfile - today it crashes with RelatedObjectDoesNotExist on every login for any affected user, via OpenedxAndApiGatewayLoginView.get().
OpenAPI ChangesShow/hide changesUnexpected changes? Ensure your branch is up-to-date with |
for more information, see https://pre-commit.ci
Django's command auto-discovery scans every .py file directly under management/commands/ as a candidate command module, so test_migrate_edx_data.py showed up (and would fail) in ./manage.py -h. Move it to management/tests/, matching the existing convention used by every other management command test in this app (and in users/).
| affected_users = list( | ||
| User.objects.filter( | ||
| Q(legal_address__isnull=True) | Q(user_profile__isnull=True) | ||
| ) | ||
| ) |
There was a problem hiding this comment.
Bug: The repair query for migrated users is too broad, potentially corrupting data for non-migrated users with missing profiles and matching emails.
Severity: LOW
Suggested Fix
Filter the initial query in _repair_migrated_user_profiles to only include users who were part of the migration. This can be done by checking for a migration-specific marker, such as a scim_external_id, instead of querying for all users missing a LegalAddress or UserProfile.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: courses/management/commands/migrate_edx_data.py#L428-L432
Potential issue: The function `_repair_migrated_user_profiles` queries for all users
missing a `LegalAddress` or `UserProfile` record. This is intended to fix users created
via `User.objects.bulk_create`, which bypasses the signals that normally create these
records. However, the query is not restricted to only migrated users. In the unlikely
event that a non-migrated user is also missing one of these records (due to some other
bug) and shares an email address with a user in the migration data, this command would
silently backfill the non-migrated user's profile with incorrect data from the migrated
user, causing data corruption.
There was a problem hiding this comment.
@shaidar if the plan is to use edxorg_to_mitxonline_users to fix the migrated users who are missing a LegalAddress/UserProfile, then this is the right place. But Q(legal_address__isnull=True) | Q(user_profile__isnull=True) matches every user in the database missing one of these rows, including users who were not part of the edX users we migrated, and we won't have the data to populate theirs.
The simple fix might be to create a blank LegalAddress/UserProfile @rhysyngsun any thoughts on whether we scope this to migrated users only, or backfill blanks for everyone missing a row?
There was a problem hiding this comment.
I think because this command is scoped to repair migrated users, let's just leave the case as-is for now and we'll look into repairing everyone else separately. I mentioned yesterday that users somehow not having a legaladdress/profile seems to be happening through other means so we need some kind of self-healing mechanism that repairs these users on an ongoing basis but that should be it's own PR.
There was a problem hiding this comment.
Sounds good - leaving the query as-is here. Happy to help scope the self-healing follow-up once we've got a sense of how it's happening outside this migration path.
should_skip_onboarding no longer raises for a user with no UserProfile, but GatewayLoginView.get() still did `profile = user.user_profile` immediately afterward whenever should_skip_onboarding is False - which is now exactly the case for these users - so the same RelatedObjectDoesNotExist just moved one line down into the view instead of being fixed. Use get_or_create so the missing row is created here instead of crashing.
| affected_users = list( | ||
| User.objects.filter( | ||
| Q(legal_address__isnull=True) | Q(user_profile__isnull=True) | ||
| ) | ||
| ) |
There was a problem hiding this comment.
I think because this command is scoped to repair migrated users, let's just leave the case as-is for now and we'll look into repairing everyone else separately. I mentioned yesterday that users somehow not having a legaladdress/profile seems to be happening through other means so we need some kind of self-healing mechanism that repairs these users on an ongoing basis but that should be it's own PR.
| affected_users = list( | ||
| User.objects.filter( | ||
| Q(legal_address__isnull=True) | Q(user_profile__isnull=True) | ||
| ) |
There was a problem hiding this comment.
I think based on the fact we're only using id and email, I'd make this change to reduce the amount of data we load into memory and make it less likely we'll get oom-killed:
| ) | |
| ).only("id", "email") |
There was a problem hiding this comment.
Added in 5a8208c - good catch, these are the only two fields either helper actually needs off this queryset.
_repair_migrated_user_profiles only ever uses .id (to build user_ids/ row_lookup_by_id) and .email (to match against Trino rows) off the affected_users queryset - _bulk_create_legal_addresses/_bulk_create_user_profiles re-fetch full rows themselves via User.objects.filter(id__in=user_ids). .only() avoids loading every column for every affected user at ~63k scale.
What are the relevant tickets?
N/A
Description (What does it do?)
migrate_edx_data --type usershas a historical bug:_bulk_create_userscallsUser.objects.bulk_create(new_users, batch_size=batch_size, ignore_conflicts=True)and returns that result directly.bulk_create(ignore_conflicts=True)never populates.pkon the objects passed into it, on any backend, since it can't reliably map generated ids back to input objects when some rows are silently skipped on conflict. Every user created through this path therefore hasid=Nonein the returned list, so_migrate_usersbuildsid_row_lookupkeyed onNone, and_bulk_create_legal_addresses/_bulk_create_user_profilesboth doUser.objects.filter(id__in=[None, ...]), which matches zero rows in Postgres (IN (NULL)never matches). The net effect: every user created by this migration type ends up with noLegalAddressorUserProfilerow at all - confirmed in production (User.objects.filter(scim_external_id__isnull=False, user_profile__isnull=True).count()→ 63,852). The root-cause fix for future runs is a separate PR; this one repairs the users already affected.This PR:
migrate_edx_data --type repair_migrated_profilesthat re-scans the sameedxorg_to_mitxonline_usersTrino table, finds users already missing aLegalAddressand/orUserProfilerow, and repairs them using the existing, already-correct_bulk_create_legal_addresses/_bulk_create_user_profileshelpers - those helpers only ever broke because they were fed PK-less objects; given real, already-savedUserrows (real.id), they work exactly like they already do for the enrollments migration's opportunistic repair path (_migrate_enrollments). Supports--dry-runand--limit, matching the existing conventions in this command. Idempotent: both helpers already skip users who already have the row, so re-running is always safe.User.should_skip_onboarding(users/models.py) against a missingUserProfile. This is a separate, more urgent problem than the backfill itself: it currently doesself.user_profile.completed_onboardingwith no guard, which raisesRelatedObjectDoesNotExisttoday, in production, on every login for any of the affected users, viaOpenedxAndApiGatewayLoginView.get()'snot user.should_skip_onboardingcheck. This fix stops that crash regardless of how/when the backfill itself is run.How can this be tested?
courses/management/commands/test_migrate_edx_data.py(new) covers the newrepair_migrated_profilestype against a fake Trino cursor/connection: creating both missing rows, backfilling only the missing piece when one already exists, leaving already-correct users untouched, leaving users with no matching edX row still-broken-but-not-crashing,--dry-runwriting nothing, and--limitcapping how many get repaired.users/models_test.pyadds two cases forshould_skip_onboarding: a user with noUserProfilerow no longer crashes and falls back to the enrollment check, and the existing completed-onboarding behavior is unchanged.To validate against real data before running unbounded in production:
then a small
--limitrun, spot-checking a couple of repaired users'LegalAddress/UserProfilein the admin, before running unbounded.Additional Context
Depends on nothing merged yet, and nothing depends on it - it's independent of the SCIM name-mapping PRs (#3836, #3837) and the root-cause
_bulk_create_usersPK fix (separate PR). Verified via code reading that #3836/#3837'sLearnUserAdapteralready tolerates users missing these rows (it usesgetattr(self.obj, "user_profile", UserProfile()), which works because Django'sRelatedObjectDoesNotExistfor reverse one-to-ones is deliberately a subclass ofAttributeError), so this backfill is not a blocker for those - it's being done because it's the right fix for the underlying data, and because of the separate liveshould_skip_onboardingcrash.