fix(api): make alembic migrations idempotent for fresh databases
- 001: guard has_table check; skip if session_data already TEXT - 002: guard each ADD COLUMN / CREATE TABLE; guard password migration - 003: guard has_table; guard nullable check - 004: guard has_table; skip if users.id already TEXT - env.py: add Base.metadata.create_all after run_migrations to bootstrap fresh DBs - api/user.py: make hashed_password nullable; add email_verified, image, email_inbound_token fields Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
+3
-1
@@ -12,6 +12,8 @@ RUN pip install --no-cache-dir --prefix=/install .
|
|||||||
|
|
||||||
FROM python:3.12-slim AS prod
|
FROM python:3.12-slim AS prod
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN adduser --system --group --uid 1000 app
|
RUN adduser --system --group --uid 1000 app
|
||||||
COPY --from=build /install /usr/local
|
COPY --from=build /install /usr/local
|
||||||
@@ -25,4 +27,4 @@ EXPOSE 8000
|
|||||||
HEALTHCHECK --interval=30s --timeout=3s \
|
HEALTHCHECK --interval=30s --timeout=3s \
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
||||||
|
|
||||||
CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"]
|
||||||
|
|||||||
+5
-1
@@ -6,7 +6,7 @@ from logging.config import fileConfig
|
|||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from cartsnitch_api.models import Base # noqa: F401 — imports all models for autogenerate
|
from cartsnitch_api.models.base import Base # noqa: F401 — imports all models for autogenerate
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
@@ -47,6 +47,10 @@ def run_migrations_online() -> None:
|
|||||||
context.configure(connection=connection, target_metadata=target_metadata)
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
# Create any tables defined in models but not yet created by migrations.
|
||||||
|
# This bootstraps fresh databases that have no legacy schema.
|
||||||
|
# checkfirst=True ensures this is a no-op on existing databases.
|
||||||
|
Base.metadata.create_all(bind=connection, checkfirst=True)
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
if context.is_offline_mode():
|
||||||
|
|||||||
@@ -33,6 +33,21 @@ def _is_fernet_token(value: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
|
||||||
|
# Fresh DB — table created by Base.metadata.create_all with correct TEXT type
|
||||||
|
if not inspector.has_table("user_store_accounts"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Already migrated? Skip if session_data is already TEXT (not JSON)
|
||||||
|
cols = {c["name"]: c for c in inspector.get_columns("user_store_accounts")}
|
||||||
|
if "session_data" not in cols:
|
||||||
|
return
|
||||||
|
col_type = str(cols["session_data"]["type"]).lower()
|
||||||
|
if "text" in col_type and "json" not in col_type:
|
||||||
|
return # already TEXT — nothing to do
|
||||||
|
|
||||||
# Change column type from JSON to TEXT to hold Fernet ciphertext
|
# Change column type from JSON to TEXT to hold Fernet ciphertext
|
||||||
op.alter_column(
|
op.alter_column(
|
||||||
"user_store_accounts",
|
"user_store_accounts",
|
||||||
@@ -43,7 +58,6 @@ def upgrade() -> None:
|
|||||||
postgresql_using="session_data::text",
|
postgresql_using="session_data::text",
|
||||||
)
|
)
|
||||||
|
|
||||||
conn = op.get_bind()
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
text("SELECT id, session_data FROM user_store_accounts WHERE session_data IS NOT NULL")
|
text("SELECT id, session_data FROM user_store_accounts WHERE session_data IS NOT NULL")
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|||||||
@@ -21,81 +21,96 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
|
||||||
# --- Extend users table for Better-Auth compatibility ---
|
# --- Extend users table for Better-Auth compatibility ---
|
||||||
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
|
existing_user_cols = (
|
||||||
op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
|
[c["name"] for c in inspector.get_columns("users")]
|
||||||
|
if inspector.has_table("users")
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
if "email_verified" not in existing_user_cols:
|
||||||
|
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
|
||||||
|
if "image" not in existing_user_cols:
|
||||||
|
op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
|
||||||
|
|
||||||
# --- Create sessions table ---
|
# --- Create sessions table ---
|
||||||
op.create_table(
|
if not inspector.has_table("sessions"):
|
||||||
"sessions",
|
op.create_table(
|
||||||
sa.Column("id", sa.Text(), nullable=False),
|
"sessions",
|
||||||
sa.Column("token", sa.Text(), nullable=False),
|
sa.Column("id", sa.Text(), nullable=False),
|
||||||
sa.Column("user_id", sa.Text(), nullable=False),
|
sa.Column("token", sa.Text(), nullable=False),
|
||||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("user_id", sa.Text(), nullable=False),
|
||||||
sa.Column("ip_address", sa.Text(), nullable=True),
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
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.func.now(), nullable=False),
|
sa.Column("user_agent", sa.Text(), nullable=True),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
)
|
sa.PrimaryKeyConstraint("id"),
|
||||||
op.create_index("ix_sessions_token", "sessions", ["token"], unique=True)
|
)
|
||||||
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
op.create_index("ix_sessions_token", "sessions", ["token"], unique=True)
|
||||||
|
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
||||||
|
|
||||||
# --- Create accounts table ---
|
# --- Create accounts table ---
|
||||||
op.create_table(
|
if not inspector.has_table("accounts"):
|
||||||
"accounts",
|
op.create_table(
|
||||||
sa.Column("id", sa.Text(), nullable=False),
|
"accounts",
|
||||||
sa.Column("user_id", sa.Text(), nullable=False),
|
sa.Column("id", sa.Text(), nullable=False),
|
||||||
sa.Column("account_id", sa.Text(), nullable=False),
|
sa.Column("user_id", sa.Text(), nullable=False),
|
||||||
sa.Column("provider_id", sa.Text(), nullable=False),
|
sa.Column("account_id", sa.Text(), nullable=False),
|
||||||
sa.Column("access_token", sa.Text(), nullable=True),
|
sa.Column("provider_id", sa.Text(), nullable=False),
|
||||||
sa.Column("refresh_token", sa.Text(), nullable=True),
|
sa.Column("access_token", sa.Text(), nullable=True),
|
||||||
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("refresh_token", sa.Text(), nullable=True),
|
||||||
sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("scope", sa.Text(), nullable=True),
|
sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("id_token", sa.Text(), nullable=True),
|
sa.Column("scope", sa.Text(), nullable=True),
|
||||||
sa.Column("password", sa.Text(), nullable=True),
|
sa.Column("id_token", sa.Text(), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column("password", sa.Text(), nullable=True),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
)
|
sa.PrimaryKeyConstraint("id"),
|
||||||
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
|
)
|
||||||
|
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
|
||||||
|
|
||||||
# --- Create verifications table ---
|
# --- Create verifications table ---
|
||||||
op.create_table(
|
if not inspector.has_table("verifications"):
|
||||||
"verifications",
|
op.create_table(
|
||||||
sa.Column("id", sa.Text(), nullable=False),
|
"verifications",
|
||||||
sa.Column("identifier", sa.Text(), nullable=False),
|
sa.Column("id", sa.Text(), nullable=False),
|
||||||
sa.Column("value", sa.Text(), nullable=False),
|
sa.Column("identifier", sa.Text(), nullable=False),
|
||||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("value", sa.Text(), nullable=False),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
)
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
# --- Migrate existing password hashes to accounts table ---
|
# --- Migrate existing password hashes to accounts table ---
|
||||||
# For each user with a hashed_password, create a 'credential' account row
|
# Only run on existing (non-fresh) DBs that already have users table with data
|
||||||
conn = op.get_bind()
|
if inspector.has_table("users"):
|
||||||
users = conn.execute(
|
users = conn.execute(
|
||||||
text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL")
|
text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL")
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
for user_id, hashed_password in users:
|
for user_id, hashed_password in users:
|
||||||
user_id_str = str(user_id)
|
user_id_str = str(user_id)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
|
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
|
||||||
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
|
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
|
||||||
),
|
),
|
||||||
{"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password},
|
{"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.drop_table("verifications")
|
op.execute(text("DROP INDEX IF EXISTS ix_accounts_user_id"))
|
||||||
op.drop_table("accounts")
|
op.execute(text("DROP TABLE IF EXISTS verifications"))
|
||||||
op.drop_index("ix_sessions_user_id", table_name="sessions")
|
op.execute(text("DROP TABLE IF EXISTS accounts"))
|
||||||
op.drop_index("ix_sessions_token", table_name="sessions")
|
op.execute(text("DROP INDEX IF EXISTS ix_sessions_user_id"))
|
||||||
op.drop_table("sessions")
|
op.execute(text("DROP INDEX IF EXISTS ix_sessions_token"))
|
||||||
op.drop_column("users", "image")
|
op.execute(text("DROP TABLE IF EXISTS sessions"))
|
||||||
op.drop_column("users", "email_verified")
|
op.execute(text("ALTER TABLE users DROP COLUMN IF EXISTS image"))
|
||||||
|
op.execute(text("ALTER TABLE users DROP COLUMN IF EXISTS email_verified"))
|
||||||
|
|||||||
@@ -19,8 +19,25 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True)
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
|
||||||
|
# Fresh DB — nothing to alter
|
||||||
|
if not inspector.has_table("users"):
|
||||||
|
return
|
||||||
|
|
||||||
|
cols = {c["name"]: c for c in inspector.get_columns("users")}
|
||||||
|
if "hashed_password" in cols and not cols["hashed_password"]["nullable"]:
|
||||||
|
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False)
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
|
||||||
|
if not inspector.has_table("users"):
|
||||||
|
return
|
||||||
|
|
||||||
|
cols = {c["name"]: c for c in inspector.get_columns("users")}
|
||||||
|
if "hashed_password" in cols and cols["hashed_password"]["nullable"]:
|
||||||
|
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False)
|
||||||
|
|||||||
@@ -25,7 +25,21 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Step 1: Drop existing FK constraints
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
|
||||||
|
# Fresh DB — no tables yet, nothing to convert
|
||||||
|
if not inspector.has_table("users"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if already TEXT (Base.metadata.create_all uses TEXT for fresh DB)
|
||||||
|
users_cols = {c["name"]: c for c in inspector.get_columns("users")}
|
||||||
|
if "id" in users_cols:
|
||||||
|
id_type = str(users_cols["id"]["type"]).lower()
|
||||||
|
if "text" in id_type and "uuid" not in id_type:
|
||||||
|
return # already TEXT — nothing to do
|
||||||
|
|
||||||
|
# Step 1: Drop existing FK constraints (ignore if they don't exist)
|
||||||
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
|
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
|
||||||
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
|
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import secrets
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from cartsnitch_api.constants import AccountStatus
|
from cartsnitch_api.constants import AccountStatus
|
||||||
@@ -23,13 +24,20 @@ class User(TimestampMixin, Base):
|
|||||||
|
|
||||||
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
display_name: Mapped[str | None] = mapped_column(String(100))
|
display_name: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
email_verified: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, server_default="false"
|
||||||
|
)
|
||||||
|
image: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
email_inbound_token: Mapped[str] = mapped_column(
|
email_inbound_token: Mapped[str] = mapped_column(
|
||||||
String(22),
|
String(22),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
unique=True,
|
unique=True,
|
||||||
default=lambda: secrets.token_urlsafe(16),
|
default=lambda: secrets.token_urlsafe(16),
|
||||||
|
server_default=sa.text(
|
||||||
|
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
Reference in New Issue
Block a user