"""Мультиюзерность: users, mcp_tokens, user_id на таблицах, per-user настройки.
- users: профиль из SSO, upsert при логине; FK-цель для user_id.
- user_id (nullable) на 7 таблицах: существующие строки остаются бесхозными
(NULL) и приписываются пользователю при первом логине (claim, ТЗ 1.2).
- Уникальности имени проекта/тега становятся per-user (user_id, name).
- uq_coin_daily/uq_coin_level становятся per-user.
- app_settings → app_settings_global (источник claim), новая app_settings
с PK (user_id, key).
Тесты создают схему через Base.metadata.create_all — эта миграция только
для Postgres.
Revision ID: f9a0b1c2d3e4
Revises: e7f8a9b0c1d2
Create Date: 2026-09-22
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "f9a0b1c2d3e4"
down_revision: str | None = "e7f8a9b0c1d2"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
USER_TABLES = (
"projects",
"tasks",
"tags",
"documents",
"xp_events",
"coin_events",
"garden_items",
)
def upgrade() -> None:
# 1. Пользователи и MCP-токены
op.create_table(
"users",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("email", sa.String(255), nullable=False, server_default=""),
sa.Column("avatar_url", sa.String(500), nullable=True),
sa.Column("locale", sa.String(16), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_table(
"mcp_tokens",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.String(64), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
sa.Column("label", sa.String(100), nullable=False, server_default=""),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
# 2. user_id на таблицы данных (nullable — claim при первом логине)
for table in USER_TABLES:
op.add_column(table, sa.Column("user_id", sa.String(64), nullable=True))
op.create_index(f"ix_{table}_user_id", table, ["user_id"])
op.create_foreign_key(
f"fk_{table}_user_id_users", table, "users", ["user_id"], ["id"]
)
# 3. Имена проектов/тегов — уникальны в пределах пользователя
op.drop_constraint("projects_name_key", "projects", type_="unique")
op.drop_constraint("tags_name_key", "tags", type_="unique")
op.create_index("uq_projects_user_name", "projects", ["user_id", "name"], unique=True)
op.create_index("uq_tags_user_name", "tags", ["user_id", "name"], unique=True)
# 4. Дейли/уровень — награды per-user
op.drop_index("uq_coin_daily", table_name="coin_events")
op.drop_index("uq_coin_level", table_name="coin_events")
op.create_index(
"uq_coin_daily",
"coin_events",
["user_id", "source", "day"],
unique=True,
postgresql_where=sa.text("source = 'daily'"),
)
op.create_index(
"uq_coin_level",
"coin_events",
["user_id", "level"],
unique=True,
postgresql_where=sa.text("source = 'level' AND level IS NOT NULL"),
)
# 5. Настройки: глобальные → источник claim, новые — per-user
op.rename_table("app_settings", "app_settings_global")
op.create_table(
"app_settings",
sa.Column("user_id", sa.String(64), primary_key=True),
sa.Column("key", sa.String(100), primary_key=True),
sa.Column("value", sa.String(200), nullable=False),
)
def downgrade() -> None:
# Данные per-user настроек теряются (остаются только глобальные)
op.drop_table("app_settings")
op.rename_table("app_settings_global", "app_settings")
op.drop_index("uq_coin_level", table_name="coin_events")
op.drop_index("uq_coin_daily", table_name="coin_events")
op.create_index(
"uq_coin_daily",
"coin_events",
["source", "day"],
unique=True,
postgresql_where=sa.text("source = 'daily'"),
)
op.create_index(
"uq_coin_level",
"coin_events",
["level"],
unique=True,
postgresql_where=sa.text("source = 'level' AND level IS NOT NULL"),
)
op.create_unique_constraint("projects_name_key", "projects", ["name"])
op.create_unique_constraint("tags_name_key", "tags", ["name"])
op.drop_index("uq_tags_user_name", table_name="tags")
op.drop_index("uq_projects_user_name", table_name="projects")
for table in USER_TABLES:
op.drop_constraint(f"fk_{table}_user_id_users", table, type_="foreignkey")
op.drop_index(f"ix_{table}_user_id", table_name=table)
op.drop_column(table, "user_id")
op.drop_table("mcp_tokens")
op.drop_table("users")