Phase 1 Database Implementation (DB-001 through DB-012): Models: - User: OAuth support (Google/Discord), premium subscriptions - Collection: Card ownership with CardSource enum - Deck: JSONB cards/energy_cards, validation state - CampaignProgress: One-to-one with User, medals/NPCs as JSONB - ActiveGame: In-progress games with GameType enum - GameHistory: Completed games with EndReason enum, replay data Infrastructure: - Alembic migrations with sync psycopg2 (avoids async issues) - Docker Compose for Postgres (5433) and Redis (6380) - App config with Pydantic settings - Redis client helper Test Infrastructure: - 68 database tests (47 model + 21 relationship) - Async factory pattern for test data creation - Sync TRUNCATE cleanup (solves pytest-asyncio event loop mismatch) - Uses dev containers instead of testcontainers for reliability Key technical decisions: - passive_deletes=True for ON DELETE SET NULL relationships - NullPool for test sessions (no connection reuse) - expire_on_commit=False with manual expire() for relationship tests
284 lines
12 KiB
Python
284 lines
12 KiB
Python
"""initial_schema
|
|
|
|
Revision ID: 7ac994d6f89c
|
|
Revises:
|
|
Create Date: 2026-01-27 08:52:08.554218
|
|
|
|
"""
|
|
|
|
from collections.abc import Sequence
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
from sqlalchemy.dialects import postgresql
|
|
|
|
# revision identifiers, used by Alembic.
|
|
revision: str = "7ac994d6f89c"
|
|
down_revision: str | Sequence[str] | None = None
|
|
branch_labels: str | Sequence[str] | None = None
|
|
depends_on: str | Sequence[str] | None = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
"""Upgrade schema."""
|
|
# ### commands auto generated by Alembic - please adjust! ###
|
|
op.create_table(
|
|
"users",
|
|
sa.Column("email", sa.String(length=255), nullable=False),
|
|
sa.Column("display_name", sa.String(length=50), nullable=False),
|
|
sa.Column("avatar_url", sa.String(length=500), nullable=True),
|
|
sa.Column("oauth_provider", sa.String(length=20), nullable=False),
|
|
sa.Column("oauth_id", sa.String(length=255), nullable=False),
|
|
sa.Column("is_premium", sa.Boolean(), nullable=False),
|
|
sa.Column("premium_until", sa.DateTime(timezone=True), nullable=True),
|
|
sa.Column("last_login", sa.DateTime(timezone=True), nullable=True),
|
|
sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
|
|
sa.Column(
|
|
"created_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.text("now()"),
|
|
nullable=False,
|
|
),
|
|
sa.Column(
|
|
"updated_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.text("now()"),
|
|
nullable=False,
|
|
),
|
|
sa.PrimaryKeyConstraint("id"),
|
|
)
|
|
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
|
op.create_index("ix_users_oauth", "users", ["oauth_provider", "oauth_id"], unique=True)
|
|
op.create_table(
|
|
"active_games",
|
|
sa.Column(
|
|
"game_type",
|
|
sa.Enum(
|
|
"CAMPAIGN",
|
|
"FREEPLAY",
|
|
"RANKED",
|
|
"PRACTICE",
|
|
name="game_type",
|
|
create_constraint=True,
|
|
),
|
|
nullable=False,
|
|
),
|
|
sa.Column("player1_id", sa.UUID(as_uuid=False), nullable=False),
|
|
sa.Column("player2_id", sa.UUID(as_uuid=False), nullable=True),
|
|
sa.Column("npc_id", sa.String(length=100), nullable=True),
|
|
sa.Column("rules_config", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
sa.Column("game_state", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
sa.Column("turn_number", sa.Integer(), nullable=False),
|
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column("last_action_at", sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column("turn_deadline", sa.DateTime(timezone=True), nullable=True),
|
|
sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
|
|
sa.Column(
|
|
"created_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.text("now()"),
|
|
nullable=False,
|
|
),
|
|
sa.Column(
|
|
"updated_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.text("now()"),
|
|
nullable=False,
|
|
),
|
|
sa.ForeignKeyConstraint(["player1_id"], ["users.id"], ondelete="CASCADE"),
|
|
sa.ForeignKeyConstraint(["player2_id"], ["users.id"], ondelete="SET NULL"),
|
|
sa.PrimaryKeyConstraint("id"),
|
|
)
|
|
op.create_index("ix_active_games_player1", "active_games", ["player1_id"], unique=False)
|
|
op.create_index("ix_active_games_player2", "active_games", ["player2_id"], unique=False)
|
|
op.create_index(
|
|
"ix_active_games_players", "active_games", ["player1_id", "player2_id"], unique=False
|
|
)
|
|
op.create_table(
|
|
"campaign_progress",
|
|
sa.Column("user_id", sa.UUID(as_uuid=False), nullable=False),
|
|
sa.Column("current_club", sa.String(length=50), nullable=False),
|
|
sa.Column("medals", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
sa.Column("defeated_npcs", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
sa.Column("total_wins", sa.Integer(), nullable=False),
|
|
sa.Column("total_losses", sa.Integer(), nullable=False),
|
|
sa.Column("booster_packs", sa.Integer(), nullable=False),
|
|
sa.Column("mantibucks", sa.Integer(), nullable=False),
|
|
sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
|
|
sa.Column(
|
|
"created_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.text("now()"),
|
|
nullable=False,
|
|
),
|
|
sa.Column(
|
|
"updated_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.text("now()"),
|
|
nullable=False,
|
|
),
|
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
sa.PrimaryKeyConstraint("id"),
|
|
sa.UniqueConstraint("user_id"),
|
|
sa.UniqueConstraint("user_id", name="uq_campaign_user"),
|
|
)
|
|
op.create_table(
|
|
"collections",
|
|
sa.Column("user_id", sa.UUID(as_uuid=False), nullable=False),
|
|
sa.Column("card_definition_id", sa.String(length=100), nullable=False),
|
|
sa.Column("quantity", sa.Integer(), nullable=False),
|
|
sa.Column(
|
|
"source",
|
|
sa.Enum(
|
|
"STARTER",
|
|
"BOOSTER",
|
|
"REWARD",
|
|
"PURCHASE",
|
|
"TRADE",
|
|
"GIFT",
|
|
name="card_source",
|
|
create_constraint=True,
|
|
),
|
|
nullable=False,
|
|
),
|
|
sa.Column("obtained_at", sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
|
|
sa.Column(
|
|
"created_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.text("now()"),
|
|
nullable=False,
|
|
),
|
|
sa.Column(
|
|
"updated_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.text("now()"),
|
|
nullable=False,
|
|
),
|
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
sa.PrimaryKeyConstraint("id"),
|
|
sa.UniqueConstraint("user_id", "card_definition_id", name="uq_collection_user_card"),
|
|
)
|
|
op.create_index("ix_collection_card", "collections", ["card_definition_id"], unique=False)
|
|
op.create_index(op.f("ix_collections_user_id"), "collections", ["user_id"], unique=False)
|
|
op.create_table(
|
|
"decks",
|
|
sa.Column("user_id", sa.UUID(as_uuid=False), nullable=False),
|
|
sa.Column("name", sa.String(length=100), nullable=False),
|
|
sa.Column("cards", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
sa.Column("energy_cards", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
sa.Column("is_valid", sa.Boolean(), nullable=False),
|
|
sa.Column("validation_errors", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
|
sa.Column("is_starter", sa.Boolean(), nullable=False),
|
|
sa.Column("starter_type", sa.String(length=20), nullable=True),
|
|
sa.Column("description", sa.Text(), nullable=True),
|
|
sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
|
|
sa.Column(
|
|
"created_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.text("now()"),
|
|
nullable=False,
|
|
),
|
|
sa.Column(
|
|
"updated_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.text("now()"),
|
|
nullable=False,
|
|
),
|
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
sa.PrimaryKeyConstraint("id"),
|
|
)
|
|
op.create_index(op.f("ix_decks_user_id"), "decks", ["user_id"], unique=False)
|
|
op.create_index("ix_decks_user_name", "decks", ["user_id", "name"], unique=False)
|
|
op.create_table(
|
|
"game_history",
|
|
sa.Column(
|
|
"game_type",
|
|
sa.Enum(
|
|
"CAMPAIGN",
|
|
"FREEPLAY",
|
|
"RANKED",
|
|
"PRACTICE",
|
|
name="game_type",
|
|
create_constraint=True,
|
|
),
|
|
nullable=False,
|
|
),
|
|
sa.Column("player1_id", sa.UUID(as_uuid=False), nullable=False),
|
|
sa.Column("player2_id", sa.UUID(as_uuid=False), nullable=True),
|
|
sa.Column("npc_id", sa.String(length=100), nullable=True),
|
|
sa.Column("winner_id", sa.UUID(as_uuid=False), nullable=True),
|
|
sa.Column("winner_is_npc", sa.Boolean(), nullable=False),
|
|
sa.Column(
|
|
"end_reason",
|
|
sa.Enum(
|
|
"PRIZES_TAKEN",
|
|
"NO_POKEMON",
|
|
"CANNOT_DRAW",
|
|
"RESIGNATION",
|
|
"TIMEOUT",
|
|
"TURN_LIMIT",
|
|
"DISCONNECTION",
|
|
"DRAW",
|
|
name="end_reason",
|
|
create_constraint=True,
|
|
),
|
|
nullable=False,
|
|
),
|
|
sa.Column("turn_count", sa.Integer(), nullable=False),
|
|
sa.Column("duration_seconds", sa.Integer(), nullable=False),
|
|
sa.Column("replay_data", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
|
sa.Column("played_at", sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
|
|
sa.Column(
|
|
"created_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.text("now()"),
|
|
nullable=False,
|
|
),
|
|
sa.Column(
|
|
"updated_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.text("now()"),
|
|
nullable=False,
|
|
),
|
|
sa.ForeignKeyConstraint(["player1_id"], ["users.id"], ondelete="SET NULL"),
|
|
sa.ForeignKeyConstraint(["player2_id"], ["users.id"], ondelete="SET NULL"),
|
|
sa.ForeignKeyConstraint(["winner_id"], ["users.id"], ondelete="SET NULL"),
|
|
sa.PrimaryKeyConstraint("id"),
|
|
)
|
|
op.create_index("ix_game_history_played_at", "game_history", ["played_at"], unique=False)
|
|
op.create_index("ix_game_history_player1", "game_history", ["player1_id"], unique=False)
|
|
op.create_index("ix_game_history_player2", "game_history", ["player2_id"], unique=False)
|
|
op.create_index(
|
|
"ix_game_history_type_played", "game_history", ["game_type", "played_at"], unique=False
|
|
)
|
|
op.create_index("ix_game_history_winner", "game_history", ["winner_id"], unique=False)
|
|
# ### end Alembic commands ###
|
|
|
|
|
|
def downgrade() -> None:
|
|
"""Downgrade schema."""
|
|
# ### commands auto generated by Alembic - please adjust! ###
|
|
op.drop_index("ix_game_history_winner", table_name="game_history")
|
|
op.drop_index("ix_game_history_type_played", table_name="game_history")
|
|
op.drop_index("ix_game_history_player2", table_name="game_history")
|
|
op.drop_index("ix_game_history_player1", table_name="game_history")
|
|
op.drop_index("ix_game_history_played_at", table_name="game_history")
|
|
op.drop_table("game_history")
|
|
op.drop_index("ix_decks_user_name", table_name="decks")
|
|
op.drop_index(op.f("ix_decks_user_id"), table_name="decks")
|
|
op.drop_table("decks")
|
|
op.drop_index(op.f("ix_collections_user_id"), table_name="collections")
|
|
op.drop_index("ix_collection_card", table_name="collections")
|
|
op.drop_table("collections")
|
|
op.drop_table("campaign_progress")
|
|
op.drop_index("ix_active_games_players", table_name="active_games")
|
|
op.drop_index("ix_active_games_player2", table_name="active_games")
|
|
op.drop_index("ix_active_games_player1", table_name="active_games")
|
|
op.drop_table("active_games")
|
|
op.drop_index("ix_users_oauth", table_name="users")
|
|
op.drop_index(op.f("ix_users_email"), table_name="users")
|
|
op.drop_table("users")
|
|
# ### end Alembic commands ###
|