Files
opencloud-backend/alembic/versions/20260718_0001_initial_schema.py
2026-07-18 20:32:51 +08:00

196 lines
9.3 KiB
Python

"""Initial OpenCloud schema.
Revision ID: 20260718_0001
Revises:
Create Date: 2026-07-18
"""
from typing import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260718_0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("email", sa.Text(), nullable=False),
sa.Column("password_hash", sa.Text(), nullable=False),
sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True),
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"),
sa.UniqueConstraint("email"),
)
op.create_table(
"cloud_types",
sa.Column("id", sa.SmallInteger(), autoincrement=False, nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("name_en", sa.Text(), nullable=False),
sa.Column("genus", sa.Text(), nullable=False),
sa.Column("rarity", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("icon_url", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("rarity IN ('common', 'uncommon', 'rare')", name="ck_cloud_types_rarity"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
sa.UniqueConstraint("name_en"),
)
op.create_table(
"profiles",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("username", sa.Text(), nullable=False),
sa.Column("avatar_path", sa.Text(), nullable=True),
sa.Column("role", sa.Text(), server_default="user", nullable=False),
sa.Column("is_disabled", sa.Boolean(), server_default=sa.text("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.CheckConstraint("length(trim(username)) >= 2", name="ck_profiles_username_length"),
sa.CheckConstraint("role IN ('user', 'admin')", name="ck_profiles_role"),
sa.ForeignKeyConstraint(["id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("username"),
)
op.create_table(
"refresh_sessions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("token_hash", sa.Text(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("replaced_by_id", sa.Uuid(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("ip_address", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["replaced_by_id"], ["refresh_sessions.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("ix_refresh_sessions_user_id", "refresh_sessions", ["user_id"])
op.create_index("ix_refresh_sessions_replaced_by_id", "refresh_sessions", ["replaced_by_id"])
op.create_index(
"ix_refresh_sessions_active_user",
"refresh_sessions",
["user_id", "expires_at"],
postgresql_where=sa.text("revoked_at IS NULL"),
)
op.create_table(
"email_tokens",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("purpose", sa.Text(), nullable=False),
sa.Column("token_hash", sa.Text(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("purpose IN ('confirm_email', 'reset_password')", name="ck_email_tokens_purpose"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("ix_email_tokens_user_purpose", "email_tokens", ["user_id", "purpose"])
op.create_table(
"clouds",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("cloud_type_id", sa.SmallInteger(), nullable=True),
sa.Column("custom_cloud_type", sa.Text(), nullable=True),
sa.Column("image_path", sa.Text(), nullable=False),
sa.Column("thumbnail_path", sa.Text(), nullable=False),
sa.Column("latitude", sa.Numeric(4, 2), nullable=True),
sa.Column("longitude", sa.Numeric(5, 2), nullable=True),
sa.Column("location_name", sa.Text(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("captured_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.Text(), server_default="pending", nullable=False),
sa.Column("is_hidden", sa.Boolean(), server_default=sa.text("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.CheckConstraint("status IN ('pending', 'approved', 'rejected')", name="ck_clouds_status"),
sa.CheckConstraint(
"((latitude IS NULL AND longitude IS NULL) OR (latitude IS NOT NULL AND longitude IS NOT NULL))",
name="ck_clouds_coordinate_pair",
),
sa.CheckConstraint("latitude IS NULL OR latitude BETWEEN -90 AND 90", name="ck_clouds_latitude"),
sa.CheckConstraint("longitude IS NULL OR longitude BETWEEN -180 AND 180", name="ck_clouds_longitude"),
sa.CheckConstraint(
"((cloud_type_id IS NOT NULL AND custom_cloud_type IS NULL) OR "
"(cloud_type_id IS NULL AND custom_cloud_type IS NOT NULL AND length(trim(custom_cloud_type)) > 0))",
name="ck_clouds_type_choice",
),
sa.ForeignKeyConstraint(["cloud_type_id"], ["cloud_types.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("image_path"),
sa.UniqueConstraint("thumbnail_path"),
)
op.create_index("ix_clouds_user_id", "clouds", ["user_id"])
op.create_index("ix_clouds_cloud_type_id", "clouds", ["cloud_type_id"])
op.create_index(
"ix_clouds_user_captured",
"clouds",
["user_id", sa.text("captured_at DESC"), sa.text("created_at DESC")],
)
op.create_index("ix_clouds_status_created", "clouds", ["status", sa.text("created_at DESC")])
op.create_index(
"ix_clouds_public_gallery",
"clouds",
[sa.text("created_at DESC"), sa.text("id DESC")],
postgresql_where=sa.text("status = 'approved' AND is_hidden = false"),
)
op.create_index(
"ix_clouds_public_map",
"clouds",
["captured_at"],
postgresql_where=sa.text(
"status = 'approved' AND is_hidden = false AND latitude IS NOT NULL AND longitude IS NOT NULL"
),
)
op.create_index(
"ix_clouds_public_type",
"clouds",
["cloud_type_id", sa.text("captured_at DESC")],
postgresql_where=sa.text("status = 'approved' AND is_hidden = false"),
)
op.create_table(
"user_collections",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("cloud_type_id", sa.SmallInteger(), nullable=False),
sa.Column("first_cloud_id", sa.Uuid(), nullable=True),
sa.Column("unlocked_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["cloud_type_id"], ["cloud_types.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["first_cloud_id"], ["clouds.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "cloud_type_id", name="uq_user_collections_user_type"),
)
op.create_index("ix_user_collections_user_id", "user_collections", ["user_id"])
op.create_index("ix_user_collections_cloud_type_id", "user_collections", ["cloud_type_id"])
op.create_index("ix_user_collections_first_cloud_id", "user_collections", ["first_cloud_id"])
def downgrade() -> None:
op.drop_table("user_collections")
op.drop_table("clouds")
op.drop_table("email_tokens")
op.drop_table("refresh_sessions")
op.drop_table("profiles")
op.drop_table("cloud_types")
op.drop_table("users")