internetarchive / internetarchive/openlibrary
Add ability to change username
- Dominant language
- Python
- Stars
- 6.7k
- Forks
- 2k
- Avg merge
- 2d 19h
- Merged PRs (30d)
- 138
Description
### Problem / Opportunity
Patrons cannot change their Open Library username, and a large number of them never chose the one they have.
For any account created through archive.org (which includes every Google sign-up), the OL username is not entered by the patron at all — it is derived from the IA account and then, on collision, has a random integer stapled to it:
- `OpenLibraryAccount.create()` sets `username = ia_account.itemname` and `displayname = ia_account.screenname`, with `retries=5` (`openlibrary/accounts/model.py:1019-1035`). The itemname is email-derived and immutable on the IA side.
- On any collision the username becomes `append_random_suffix(username)` — literally `f"{text}{random.randint(0, 9999)}"` (`openlibrary/accounts/model.py:46-47`, applied at `:495-504` and again for IA screennames at `:776-778`).
So patrons end up as `somename4821` permanently, and there is no code path anywhere in the codebase that renames a user. #10664 tracks fixing this **going forward** (prompt for a username at Google sign-up). This issue is the other half: giving the patrons who already have an unwanted username a way out.
The measurable outcome: a patron can change their username once and keep their reading log, lists, ratings, follows, and edit-history attribution intact — or is told clearly and up front that their account is not eligible.
### Why this is hard (and where the existing precedent stops)
`Account.anonymize()` (`openlibrary/accounts/model.py:398-437`) is the closest thing we have, and it is a useful map of the surface area — but it is a *destructive* operation, and it takes shortcuts a rename cannot:
| What anonymize does | What a rename needs instead |
|---|---|
| Deletes the profile doc: `{"key": patron.key, "type": "/type/delete"}` (`:409-410`) | Rename the infogami `thing.key` from `/people/{old}` to `/people/{new}` |
| Deletes the store docs (`:413-419`) | Move them: `account/{u}`, `account/{u}/verify`, `account/{u}/password`, `/people/{u}/preferences`, plus the `username`/`lusername` index fields inside the `account` doc that `get_by_username` actually queries (`:567-577`) and the `account-email/{email}` doc that points back at the username |
| **Deletes** all booknotes (`:428`) | Rename them |
| Renames 5 postgres tables (`:431-435`) | Same 5, plus the ones anonymize misses (below) |
Tables with a username that `anonymize()` does **not** touch:
- `follows` — both `subscriber` **and** `publisher` (`openlibrary/core/follows.py:13-14`); missing this silently drops a patron's followers
- `bookshelves_events`, `yearly_reading_goals` (`openlibrary/core/schema.sql:61-75`, `:103-109`)
- `community_edits_queue.reviewer` (only `submitter` is handled, via `update_submitter_name`), and the `comments` JSON blob, where each comment embeds a `username` (`openlibrary/core/edits.py:307-314`)
And two infogami problems with no existing API at all:
1. **There is no rename/move operation in infogami.** `new_key()` (`infogami/infobase/infobase.py:133`) allocates keys for *new* documents and is unrelated. A rename means writing one, against a `thing.key` that carries a unique index (`openlibrary/core/infobase_schema.sql:34`), with the memcache/`web.ctx.new_objects` caches invalidated for both the old and new key.
2. **Lists derive ownership from their key.** `List.get_owner()` is a regex over the key — `(/people/[^/]+)/lists/OL\d+L` (`openlibrary/core/lists/model.py:101-102`), and the type itself is registered by path (`:698`). Every one of a patron's list documents has to be renamed too, and each re-indexed in Solr under the new key with the old doc deleted (`openlibrary/solr/updater/list.py:24,116`).
### A note on the edit-history fear
Worth stating explicitly, because it changes the shape of the work: **edit-history attribution is stored as an integer foreign key, not as a username string.** `transaction.author_id int references thing` (`openlibrary/core/infobase_schema.sql:37-39`). Renaming `thing.key` therefore preserves attribution for every changeset the patron has ever made, for free — with no per-edit rewrite.
What *does* embed the old key as text is the `data` table, which holds a full JSON snapshot per revision (`thing_id, revision, data text` — `openlibrary/core/infobase_schema.sql:111-116`). But only snapshots of documents whose JSON contains the patron's key are affected: their own profile revisions and their list revisions. A patron's edits to *books and authors* contain no username anywhere in the document JSON.
This does not make the feature cheap, and it does not remove the need for the eligibility guard below — it just relocates the cost from "rewrite every edit" to "rename N documents and invalidate their caches."
### Proposal
A one-time, self-service username change with a hard eligibility gate.
**Eligibility gate (do not present the feature otherwise).** If the account has **more than 1,000 edits**, do not offer the rename at all — no disabled button, no form that fails on submit. The blast radius of cached renderings, recentchanges pages, and revision snapshots on a heavily-edited account is not something to work out inside a patron-facing request, and these accounts are rare enough to handle as admin escalations. Counting is currently `Account.get_recentchanges(limit, offset)` paged 100 at a time (`openlibrary/accounts/model.py:263`, used this way in `revert_all_user_edits`, `openlibrary/plugins/admin/code.py:69`); a cheap `count(*)` on `transaction.author_id` is worth adding rather than paging 10 times to answer one yes/no.
**Validation.** Reuse `account_validation.validate_username()` (`openlibrary/plugins/upstream/account.py:710-718`), which already checks both OL and archive.org availability. One wrinkle to decide: `ia_username_exists()` treats *any* existing IA screenname as unavailable, including the patron's own — so a patron whose IA screenname is what they actually want will be refused. Needs a carve-out.
**Atomicity.** `Model.update_username()` (`openlibrary/core/db.py:127-144`) is the shared helper, and it is not safe to build on as-is:
- it opens and commits its **own** transaction per table, so a 10-table rename is 10 independent commits with no rollback across them
- it catches `(UniqueViolation, IntegrityError)` and then falls through to `return rows_changed`, which is unbound in that path — an `UnboundLocalError`, not a handled error
- the comment says it plainly: `# assuming impossible for now, not a great assumption`
That assumption holds for anonymize, where the target is a collision-free `anonymous-{uuid}`. It does not hold for a patron-chosen target. The rename needs one transaction spanning all tables, or an explicit resumable/idempotent sequence with a recorded position.
**Session.** The session cookie is `/people/{username},{timestamp},{salt}$hash` (`openlibrary/accounts/model.py:142,177`), so a rename invalidates it. The patron must be re-authenticated as part of the flow rather than silently logged out.
**Old URL.** Decide whether `/people/{old}` 404s, 301s to the new profile, or reserves the old name against re-registration. Reserving is the safe default — releasing a name into the pool lets someone else inherit inbound links and follower expectations.
### Breakdown
- [ ] Add a cheap edit-count for an account (`count(*)` on `transaction.author_id`), and the `> 1000` eligibility check
- [ ] Add a rename primitive to infogami: `thing.key` update + cache invalidation for old and new key
- [ ] Rename all `/people/{old}/lists/OL*L` documents and re-index them in Solr (delete old key's doc)
- [ ] Move the store docs (`account/{u}`, `/verify`, `/password`, `/people/{u}/preferences`, `account-email/{email}`) and update the `username`/`lusername` index fields
- [ ] Rewrite `Model.update_username()` to be transactional across tables, and fix the unbound `rows_changed` on the exception path
- [ ] Cover every username-bearing table, including the ones `anonymize()` misses: `follows` (both columns), `bookshelves_events`, `yearly_reading_goals`, `booknotes`, `community_edits_queue.reviewer` + comments blob
- [ ] Patron-facing form under `/account`, reusing `validate_username()`, with the IA-screenname carve-out
- [ ] Re-authenticate the session after the rename
- [ ] Decide and implement old-username policy (recommend: reserve, do not release)
- [ ] Backfill `anonymize()` with the tables it currently misses, since the same coverage list applies there
- [ ] Admin-side path for the `> 1000` edits accounts that the patron-facing feature refuses
### Related files
- `openlibrary/accounts/model.py` — `anonymize()` (`:398`), `create()` (`:463`), `get_by_username()` (`:567`), IA auto-create (`:1019`), `append_random_suffix()` (`:46`)
- `openlibrary/core/db.py:127` — `update_username()`
- `openlibrary/core/schema.sql`, `openlibrary/core/infobase_schema.sql` — username columns; `thing`/`transaction`/`data`
- `openlibrary/core/follows.py`, `openlibrary/core/edits.py` — the tables anonymize misses
- `openlibrary/core/lists/model.py:101,698` — key-derived list ownership
- `openlibrary/solr/updater/list.py` — list re-indexing
- `openlibrary/plugins/upstream/account.py:710` — `validate_username()`
- `openlibrary/plugins/admin/code.py:335,412` — the admin anonymize entry point, as a model for an admin rename
### Requirements Checklist
- [ ] A patron with ≤ 1,000 edits can change their username once from their account settings
- [ ] Reading log, lists, ratings, observations, booknotes, follows (both directions), reading goals, and best-books survive the change
- [ ] Edit-history attribution survives the change, verified on an account with edits both before and after
- [ ] Every list is reachable at its new URL and appears in search under the new key
- [ ] An account with > 1,000 edits is never shown the feature
- [ ] A failed rename leaves the account in its original state, not half-renamed
- [ ] The old username cannot be claimed by another patron
- [ ] `anonymize()` covers the same table list as rename
### Stakeholders
- @mekarpeles — lead
- @seabelis — reported the originating problem in #10664; patron-support view on the eligibility cut-off and the old-URL policy
### Related
- #10664 — Account creation with Google auto-generates username (prevention; this issue is remediation for existing accounts)
Contributor guide
Assessment
This issue has not been assessed yet.