From 9edb77efba65f2e572f0881625f30dc620d5fa93 Mon Sep 17 00:00:00 2001 From: mguschin Date: Fri, 6 Mar 2026 12:26:41 +0300 Subject: [PATCH] feat: add Alembic database migrations Replace create_all() startup approach with Alembic for proper schema versioning. Includes initial migration for users and evotor_connections tables, entrypoint script that runs migrations before starting uvicorn. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile.web | 5 +- alembic.ini | 43 +++++++++++++ docker-compose.yml | 2 + docker-entrypoint.sh | 4 ++ requirements.txt | 1 + web/main.py | 7 --- web/migrations/README | 1 + web/migrations/env.py | 54 +++++++++++++++++ web/migrations/script.py.mako | 26 ++++++++ .../versions/2c15000e752b_initial.py | 60 +++++++++++++++++++ 10 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 alembic.ini create mode 100755 docker-entrypoint.sh create mode 100644 web/migrations/README create mode 100644 web/migrations/env.py create mode 100644 web/migrations/script.py.mako create mode 100644 web/migrations/versions/2c15000e752b_initial.py diff --git a/Dockerfile.web b/Dockerfile.web index 0214f15..65d67a4 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -6,5 +6,8 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY web/ ./web/ +COPY alembic.ini . +COPY docker-entrypoint.sh . +RUN chmod +x docker-entrypoint.sh -CMD ["uvicorn", "web.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["./docker-entrypoint.sh"] diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..3e51a57 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,43 @@ +[alembic] +script_location = web/migrations +prepend_sys_path = . +version_path_separator = os + +# URL is set dynamically in env.py from DATABASE_URL env var +sqlalchemy.url = + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docker-compose.yml b/docker-compose.yml index 81eb333..8600d5b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: - BASE_URL=${BASE_URL:-http://localhost:8080} volumes: - ./web:/app/web + - ./alembic.ini:/app/alembic.ini + - ./docker-entrypoint.sh:/app/docker-entrypoint.sh sync: build: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..47f5ea4 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -e +alembic upgrade head +exec uvicorn web.main:app --host 0.0.0.0 --port 8000 diff --git a/requirements.txt b/requirements.txt index f3b3e7e..1fd2565 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ bcrypt==4.2.0 pydantic-settings==2.5.2 itsdangerous==2.1.2 httpx==0.27.2 +alembic==1.13.3 diff --git a/web/main.py b/web/main.py index 8b73253..81b0c02 100644 --- a/web/main.py +++ b/web/main.py @@ -3,8 +3,6 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from web.config import settings -from web.database import engine, Base -from web.models import User, EvotorConnection # noqa: F401 — registers models with Base from web.routes import auth, profile, reset, evotor app = FastAPI(title="EvoSync — Личный кабинет") @@ -16,8 +14,3 @@ app.include_router(auth.router) app.include_router(profile.router) app.include_router(reset.router) app.include_router(evotor.router) - - -@app.on_event("startup") -def on_startup(): - Base.metadata.create_all(bind=engine) diff --git a/web/migrations/README b/web/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/web/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/web/migrations/env.py b/web/migrations/env.py new file mode 100644 index 0000000..201b506 --- /dev/null +++ b/web/migrations/env.py @@ -0,0 +1,54 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +from web.config import settings +from web.database import Base +from web.models import User, EvotorConnection # noqa: F401 — register models with Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/web/migrations/script.py.mako b/web/migrations/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/web/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/web/migrations/versions/2c15000e752b_initial.py b/web/migrations/versions/2c15000e752b_initial.py new file mode 100644 index 0000000..f272e92 --- /dev/null +++ b/web/migrations/versions/2c15000e752b_initial.py @@ -0,0 +1,60 @@ +"""initial + +Revision ID: 2c15000e752b +Revises: +Create Date: 2026-03-06 09:07:16.180639 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2c15000e752b' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("first_name", sa.String(length=100), nullable=False), + sa.Column("last_name", sa.String(length=100), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("phone", sa.String(length=20), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("is_email_confirmed", sa.Boolean(), nullable=False), + sa.Column("email_confirm_token", sa.String(length=255), nullable=True), + sa.Column("password_reset_token", sa.String(length=255), nullable=True), + sa.Column("password_reset_expires", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(), 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(op.f("ix_users_phone"), "users", ["phone"], unique=True) + + op.create_table( + "evotor_connections", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("access_token", sa.Text(), nullable=False), + sa.Column("store_id", sa.String(length=255), nullable=True), + sa.Column("store_name", sa.String(length=255), nullable=True), + sa.Column("connected_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id"), + ) + + +def downgrade() -> None: + op.drop_table("evotor_connections") + op.drop_index(op.f("ix_users_phone"), table_name="users") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users")