User Merge
Merge the users that share an email address into one.
Django’s auth.User.email isn’t unique, so two users could be created with the same address,
e.g. by the api, a manifest or a sign-up typed in a different case. Sign-in looks a user up by
email address, so such a person couldn’t sign in with a password (issue #914). Migration
account.0011_merge_duplicate_users merges them on every platform, and
account.0012_unique_user_email then makes the address unique. manage.py
merge_duplicate_users --dry-run reports what a merge would do, without changing anything.
Addresses are compared as the database compares them: without regard to case or trailing spaces. Blank addresses are not duplicates of each other, because a user who signed in with GitHub may have none.
Each group of users with one address keeps one user, the survivor: the one who signed in most recently, or, when none of them ever signed in, the oldest. Everything that the other users own is moved to the survivor, and then they are deleted:
Their UserProfiles. When the survivor already has a profile in the same account, the resources of the duplicate’s profile (LLMClients, Secrets, Plugins, api keys, …) are moved to the survivor’s profile. A resource whose name the survivor’s profile already uses is renamed
<name>_merged_<id>.Every other row that refers to the user: social sign-in links, knox tokens, the journal, onboarding hints, group and permission links, waffle flags, the admin log, … A row that the survivor already has an equivalent of, e.g. the same group, is deleted instead.
The user’s sign-in history and privileges: the survivor keeps the latest
last_login, the earliestdate_joined, and is staff, superuser or active if any of them was. A survivor whose password is unusable (a social sign-in) takes a duplicate’s usable password.
The functions work on the models of a historical app registry, as a migration’s RunPython
receives, never on the real models: those send signals, which deploy, undeploy or delete
infrastructure. historical_apps() returns the registry for code outside a migration.
- exception smarter.apps.account.user_merge.DryRunRollback[source]
Bases:
ExceptionRaised inside a dry run’s transaction, so that everything it changed is rolled back.
- smarter.apps.account.user_merge.MERGED_NAME_SUFFIX = '_merged_'
Inserted between the name and the id of a resource that is renamed because the survivor’s profile already uses its name.
- smarter.apps.account.user_merge.UNIQUE_EMAIL_COLUMN = 'email_unique'
the invisible virtual column that the index is on,
NULLfor a blank address.- Type:
MariaDB and MySQL
- smarter.apps.account.user_merge.UNIQUE_EMAIL_INDEX = 'auth_user_email_unique'
The unique index on
auth_user’s email addresses.
- class smarter.apps.account.user_merge.UserMergeReport(email, survivor_id, duplicate_ids=<factory>, moved=<factory>, renamed=<factory>, deleted=<factory>)[source]
Bases:
objectWhat merging one group of users with the same email address did, or would do in a dry run.
- __init__(email, survivor_id, duplicate_ids=<factory>, moved=<factory>, renamed=<factory>, deleted=<factory>)
- moved: list[str]
The rows that were moved to the survivor or to one of their profiles, e.g.
secret.Secret 12.
- renamed: list[str]
The resources that were renamed, because the survivor’s profile already uses their name.
- smarter.apps.account.user_merge.clear_cache()[source]
Clear the cache after a merge.
A merge sends no signals, so the receivers that invalidate a user’s cached objects don’t run. The cache would still hold the deleted users, and the survivor’s profiles without the ones that were moved to it. A cache that can’t be reached, e.g. while a new platform migrates, is logged and skipped: it holds nothing to clear.
- Return type:
- smarter.apps.account.user_merge.create_unique_email_index(schema_editor)[source]
Make
auth_user.emailunique, ignoring blank addresses.auth.Userbelongs to Django, so the index is created with SQL rather than a model constraint. A user who signed in with GitHub may have no address, so blank addresses are left out of it. MariaDB and MySQL have no partial indexes: the index is on an invisible virtual column that isNULLfor a blank address, and a unique index allows any number ofNULLvalues. The column’s collation ignores case and trailing spaces, asemail_key()does. Other databases get a partial index on the lower-cased address.- Parameters:
schema_editor (
BaseDatabaseSchemaEditor) – The schema editor of a migration, orconnection.schema_editor().- Raises:
django.db.IntegrityError – when two users still have the same address.
- Return type:
- smarter.apps.account.user_merge.drop_unique_email_index(schema_editor)[source]
Undo
create_unique_email_index().- Parameters:
schema_editor (
BaseDatabaseSchemaEditor) – The schema editor of a migration, orconnection.schema_editor().- Return type:
- smarter.apps.account.user_merge.email_key(email)[source]
Return the form of an email address that the database’s unique index compares.
MariaDB’s
utf8mb4_unicode_cicollation ignores case and trailing spaces.
- smarter.apps.account.user_merge.find_conflict(model, fk_field, row, new_target_id)[source]
Return the unique fields that
rowwould violate if its foreign key pointed atnew_target_id.- Parameters:
- Return type:
- Returns:
The violated set of fields, or
Noneif the row can be moved as it is.
- smarter.apps.account.user_merge.find_duplicate_users(apps)[source]
Find the groups of users that share an email address.
- smarter.apps.account.user_merge.historical_apps()[source]
Return the app registry of the migrations that the database has applied.
Its models are the historical models that a migration works with. They have no custom methods and send no signals, so a merge can’t trigger a deploy or a DNS change. They match the tables as they are, so a dry run works on a database that a newer version hasn’t migrated yet.
- Return type:
Apps- Returns:
The app registry.
- smarter.apps.account.user_merge.merge_duplicate_users(apps, dry_run=False)[source]
Merge every group of users that share an email address into one user.
Each group is merged in its own transaction. In a dry run, each transaction is rolled back, so that the reports say exactly what a merge would do.
- Parameters:
apps (
Apps) – The historical app registry: a migration’s, orhistorical_apps().dry_run (
bool) – Report the merges without keeping them.
- Return type:
- Returns:
One report per group.
- smarter.apps.account.user_merge.merge_user(apps, survivor, duplicate, report)[source]
Move everything that one user owns to another, then delete it.
- Parameters:
apps (
Apps) – The historical app registry.survivor (
Model) – The user who is kept.duplicate (
Model) – The user who is merged into it and deleted.report (
UserMergeReport) – Collects what was moved, renamed and deleted.
- Return type:
- smarter.apps.account.user_merge.merge_user_fields(survivor, duplicate)[source]
Give the survivor the duplicate’s sign-in history and privileges, where the survivor’s are weaker.
Point every row that refers to one user, or one profile, at another.
Every reverse relation of
target_modelis followed, including those of other apps and the automatic tables of many-to-many fields, so that a new model is merged without a code change. A row that would violate a unique set of fields that includesnameis renamed. A row that would violate any other, e.g. the survivor’s link to the same group, is deleted.- Parameters:
target_model (
type[Model]) –auth.Useroraccount.UserProfile.old_id (
int) – The id of the duplicate, or of its profile.new_id (
int) – The id of the survivor, or of its profile.report (
UserMergeReport) – Collects what was moved, renamed and deleted.skip_models (
tuple[type[Model],...]) – Related models that the caller merges itself.
- Return type:
- smarter.apps.account.user_merge.survivor_sort_key(user)[source]
Order users so that the one who should survive a merge comes first.
That is the one who signed in most recently. Users who never signed in come after, oldest first, then by id.