A custom User model, swapped in on the auth_user table¶
Email is the login, and django-allauth handles it on Django's default User without
complaint. It fills username with a generated value nobody reads. We swap to our own
accounts.User(AbstractUser) anyway, and drop username. email becomes unique=True,
USERNAME_FIELD = "email" and ACCOUNT_USER_MODEL_USERNAME_FIELD = None. The model then
says what the product says, and /admin/ logs in the same way the front door does.
The deciding reason is timing. Swapping AUTH_USER_MODEL after migrations exist is hard
because of the data behind it, and there will never be less of that than now: two real
people, with downtime acceptable. Every Circle that signs up makes the swap more expensive.
The default model would also have ruled out the next thing we need: approval state is a
field on the person, and you can't add fields to auth.User.
How production gets there¶
The swap happens in place, not by dump-and-reload, in two steps inside the same
downtime window as the Item.circle migration:
- Swap with no schema change. The new model keeps
db_table = "auth_user", forever. Its many-to-many tables are named after that table, soauth_user_groupsandauth_user_user_permissionsstay as they are. Beforemigrateruns, a one-off SQL step in the deploy runbook does two things. It insertsaccounts.0001_initialintodjango_migrations. It also moves the user row indjango_content_typefromauthtoaccounts. Without the first,migrateraisesInconsistentMigrationHistory:items.0001andnotifications.0001depend on the swappable user migration, and that migration would not be marked applied. That step is the idempotentmanage.py prepare_user_swapcommand, anddeploy/rehearse-user-swap.shrehearses it withmigrateon a copy. - Reshape with ordinary migrations. A data migration fills
emailfor the two existing people from deploy-time configuration. It also fillsfirst_namefrom their oldusername. Finally it creates each person's allauthEmailAddressas verified and primary. It fails loudly if either address is missing. Thenusernameis dropped andemailmade unique. On SQLite, dropping the column rebuilds the table, which is fine at this size.
The table stays named auth_user. Renaming it would buy nothing.
Consequences¶
- One email address per person.
ACCOUNT_CHANGE_EMAIL = Truemeans a change is made by verifying a new address, which then replaces the old one. It's never a second address kept alongside.User.emailand the person'sEmailAddresstherefore always agree. "Who is this address" has one answer: theUserwhoseemailequals it, lowercased. - Uniqueness and case follow allauth's rule, with no additions. Addresses are lowercased on the way in.
A verified address is unique in the database (
unique_verified_email). An unverified one is refused at signup by the form.unique=TrueonUser.emailcovers the rest. - A person with an address "already has an account", verified or not. An unverified signup still holds its address, so an invite can't treat it as free.
- Approval is a field on
User, neveris_active:PasswordResetFormand allauth both filter onis_active, so reusing it would break password reset for exactly the people waiting to be let in. Circle membership stays in its own table (ADR-0005). - The name shown for a person is
first_name, labelled "Name". It's required at signup and at invite acceptance throughACCOUNT_SIGNUP_FORM_CLASS, and it isn't unique. It replacesusernamein_avatar.htmland_desk_sidebar.html. - Tests import
django.contrib.auth.models.Userand callcreate_user(username=…)throughout. Both change toget_user_model()andemail=.