thermograph/backend/alembic/versions/0005_oauth_accounts.py
emi deb039ee24
All checks were successful
Sync infra to hosts / sync-beta (push) Has been skipped
Sync infra to hosts / sync-prod (push) Has been skipped
Sync infra to hosts / sync-dev (push) Successful in 11s
secrets-guard / encrypted (push) Successful in 17s
shell-lint / shellcheck (push) Successful in 15s
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 1m5s
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 1m8s
Deploy / deploy (backend) (push) Successful in 1m29s
Deploy / deploy (frontend) (push) Successful in 1m38s
secrets-guard / encrypted (pull_request) Successful in 8s
PR build (required check) / changes (pull_request) Successful in 10s
shell-lint / shellcheck (pull_request) Successful in 11s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 59s
PR build (required check) / build-backend (pull_request) Successful in 1m22s
PR build (required check) / gate (pull_request) Successful in 1s
accounts: sign in with Google, on a shared provider-agnostic OAuth engine (#122)
2026-07-27 00:56:43 +00:00

104 lines
4.7 KiB
Python

"""oauth_account table; user.discord_only -> user.oauth_only
Generalises "sign in with Discord" to any provider (see accounts/oauth.py). Three
steps, each conditional so the revision is safe on a database created before or
after the ORM gained these definitions — revision 0001 builds the schema with
``Base.metadata.create_all`` off *live* model metadata, so a freshly created
database already has both the table and the renamed column, and an unconditional
DDL would fail there with duplicate-object errors.
The backfill is the load-bearing step. Discord identities used to live in
``user.discord_id``, and account resolution now keys on ``oauth_account``; without
copying the existing rows across, every already-linked user would silently stop
being recognised at login and would get a *second* account created on their next
sign-in. ``user.discord_id`` is deliberately left in place — it is the DM delivery
address notify.py reads, not merely an identity.
Revision ID: 0005_oauth_accounts
Revises: 0004_bookmarks
Create Date: 2026-07-26
"""
import time
import sqlalchemy as sa
from alembic import op
# The same UUID type the ORM uses for user.id — a plain sa.Uuid renders differently
# on SQLite and would not match the column create_all produces.
from fastapi_users_db_sqlalchemy.generics import GUID
revision = "0005_oauth_accounts"
down_revision = "0004_bookmarks"
branch_labels = None
depends_on = None
def _columns(table: str) -> set[str]:
return {c["name"] for c in sa.inspect(op.get_bind()).get_columns(table)}
def _tables() -> set[str]:
return set(sa.inspect(op.get_bind()).get_table_names())
def upgrade() -> None:
cols = _columns("user")
# 1. Rename the flag. It no longer means "Discord created this account", it
# means "no password its owner has ever seen, whichever provider made it".
if "oauth_only" in cols:
# A database built by 0001 already has oauth_only (create_all reads *current*
# metadata), and 0003 then re-added an empty discord_only beside it, because
# 0003 tests for the name it knew. Drop that vestige, or a fresh database
# carries a column an upgraded one does not — schema drift that would only
# surface much later. Safe: on this path the column was created empty
# moments ago and nothing has ever read it.
if "discord_only" in cols:
op.drop_column("user", "discord_only")
elif "discord_only" in cols:
# The real upgrade path: a deployed database carrying live values. Renamed,
# never dropped, so nothing is lost.
op.alter_column("user", "discord_only", new_column_name="oauth_only")
else:
# Database predates even discord_only (never deployed with it).
op.add_column("user", sa.Column("oauth_only", sa.Boolean(), nullable=False,
server_default=sa.false()))
# 2. The identity table.
if "oauth_account" not in _tables():
op.create_table(
"oauth_account",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", GUID(), sa.ForeignKey("user.id", ondelete="CASCADE"),
nullable=False),
sa.Column("provider", sa.String(length=32), nullable=False),
sa.Column("subject", sa.String(length=64), nullable=False),
sa.Column("email", sa.String(length=320), nullable=True),
sa.Column("created_at", sa.Float(), nullable=False),
sa.UniqueConstraint("provider", "subject", name="uq_oauth_provider_subject"),
sa.UniqueConstraint("user_id", "provider", name="uq_oauth_user_provider"),
)
op.create_index("idx_oauth_user", "oauth_account", ["user_id"])
# 3. Backfill every existing Discord link. Guarded by NOT EXISTS so re-running
# (or running after create_all already produced the table) cannot violate
# either unique constraint.
op.execute(sa.text("""
INSERT INTO oauth_account (user_id, provider, subject, email, created_at)
SELECT u.id, 'discord', u.discord_id, u.email, :now
FROM "user" u
WHERE u.discord_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM oauth_account o
WHERE o.provider = 'discord'
AND (o.subject = u.discord_id OR o.user_id = u.id)
)
""").bindparams(now=time.time()))
def downgrade() -> None:
if "oauth_account" in _tables():
op.drop_index("idx_oauth_user", table_name="oauth_account")
op.drop_table("oauth_account")
cols = _columns("user")
if "oauth_only" in cols and "discord_only" not in cols:
op.alter_column("user", "oauth_only", new_column_name="discord_only")