"""OIDC identity + avatar / display_name on users Revision ID: 0015_oidc_and_avatar Revises: 0014_share_status Create Date: 2026-04-22 Lets users sign in via an OIDC provider (Authentik) and carry a profile image / display name from the provider. Password-only users are unaffected. 1. Add users.oidc_issuer, users.oidc_sub (identity pair from the IdP). 2. Add users.avatar_url, users.display_name (profile bits from claims or manually set). 3. Make users.hashed_password nullable — OIDC-only users have no local password. Existing rows all have hashes so the NULLability change is backwards-compatible. 4. Partial unique index on (oidc_issuer, oidc_sub) WHERE oidc_sub IS NOT NULL so multiple password-only users (both NULL) don't collide. """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = "0015_oidc_and_avatar" down_revision: Union[str, None] = "0014_share_status" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: conn = op.get_bind() # 1 + 2. Add new columns (idempotent — create_all on fresh installs # already built them from the model). for col_def in ( "oidc_issuer VARCHAR", "oidc_sub VARCHAR", "avatar_url VARCHAR", "display_name VARCHAR", ): conn.execute(sa.text(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {col_def}")) # 3. Drop NOT NULL on hashed_password. Postgres only — SQLite can't # alter column nullability in place, but the SQLite escape hatch is # used for fresh local dev where create_all already wrote the new # nullable definition. if conn.dialect.name == "postgresql": conn.execute(sa.text( "ALTER TABLE users ALTER COLUMN hashed_password DROP NOT NULL" )) # 4. Partial unique index — Postgres supports the WHERE clause so # NULLs don't collide; SQLite treats NULLs as distinct in unique # indexes already, so a plain unique index is safe there too. if conn.dialect.name == "postgresql": conn.execute(sa.text( "CREATE UNIQUE INDEX IF NOT EXISTS ix_users_oidc_identity " "ON users (oidc_issuer, oidc_sub) WHERE oidc_sub IS NOT NULL" )) else: conn.execute(sa.text( "CREATE UNIQUE INDEX IF NOT EXISTS ix_users_oidc_identity " "ON users (oidc_issuer, oidc_sub)" )) def downgrade() -> None: conn = op.get_bind() conn.execute(sa.text("DROP INDEX IF EXISTS ix_users_oidc_identity")) if conn.dialect.name == "postgresql": # Can't re-apply NOT NULL if any OIDC-only user has NULL — so # only do it when safe. conn.execute(sa.text( "UPDATE users SET hashed_password = '' WHERE hashed_password IS NULL" )) conn.execute(sa.text( "ALTER TABLE users ALTER COLUMN hashed_password SET NOT NULL" )) for col in ("display_name", "avatar_url", "oidc_sub", "oidc_issuer"): conn.execute(sa.text(f"ALTER TABLE users DROP COLUMN IF EXISTS {col}"))