Skip to content

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:

  1. Swap with no schema change. The new model keeps db_table = "auth_user", forever. Its many-to-many tables are named after that table, so auth_user_groups and auth_user_user_permissions stay as they are. Before migrate runs, a one-off SQL step in the deploy runbook does two things. It inserts accounts.0001_initial into django_migrations. It also moves the user row in django_content_type from auth to accounts. Without the first, migrate raises InconsistentMigrationHistory: items.0001 and notifications.0001 depend on the swappable user migration, and that migration would not be marked applied. That step is the idempotent manage.py prepare_user_swap command, and deploy/rehearse-user-swap.sh rehearses it with migrate on a copy.
  2. Reshape with ordinary migrations. A data migration fills email for the two existing people from deploy-time configuration. It also fills first_name from their old username. Finally it creates each person's allauth EmailAddress as verified and primary. It fails loudly if either address is missing. Then username is dropped and email made 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 = True means a change is made by verifying a new address, which then replaces the old one. It's never a second address kept alongside. User.email and the person's EmailAddress therefore always agree. "Who is this address" has one answer: the User whose email equals 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=True on User.email covers 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, never is_active: PasswordResetForm and allauth both filter on is_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 through ACCOUNT_SIGNUP_FORM_CLASS, and it isn't unique. It replaces username in _avatar.html and _desk_sidebar.html.
  • Tests import django.contrib.auth.models.User and call create_user(username=…) throughout. Both change to get_user_model() and email=.