diff --git a/.env.example b/.env.example index 713a1ee..526d990 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ # PostgreSQL Database Configuration -POSTGRES_DB=your_database_name -POSTGRES_USER=your_username -POSTGRES_PASSWORD=your_secure_password -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 +POSTGRES_DATABASE_DB=your_database_name +POSTGRES_DATABASE_USER=your_username +POSTGRES_DATABASE_PASSWORD=your_secure_password +POSTGRES_DATABASE_HOST=postgres +POSTGRES_DATABASE_PORT=5432 # FastAPI Configuration PYTHONPATH=/app diff --git a/app/config.py b/app/config.py index 0afd3a1..f47ad35 100644 --- a/app/config.py +++ b/app/config.py @@ -1,9 +1,10 @@ -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict from typing import Optional class Settings(BaseSettings): # Base app settings + model_config = SettingsConfigDict(env_file=".env") app_name: str = "F1tness Parser API" version: str = "0.0.1" debug: bool = False @@ -12,13 +13,10 @@ class Settings(BaseSettings): POSTGRES_DATABASE_URL: Optional[str] = None POSTGRES_DATABASE_HOST: str = "localhost" POSTGRES_DATABASE_PORT: int = 5432 - POSTGRES_DATABASE_NAME: str = "f1tness_db" + POSTGRES_DATABASE_DB: str = "f1tness_db" POSTGRES_DATABASE_USER: str = "postgres" POSTGRES_DATABASE_PASSWORD: str = "password" - class Config: - env_file = ".env" - def get_postgres_database_url(self, async_driver: bool = True) -> str: """Method for receiving relational database URL""" @@ -30,9 +28,8 @@ class Settings(BaseSettings): return ( f"{driver}://{self.POSTGRES_DATABASE_USER}:{self.POSTGRES_DATABASE_PASSWORD}" f"@{self.POSTGRES_DATABASE_HOST}" - f":{self.POSTGRES_DATABASE_PORT}/{self.POSTGRES_DATABASE_NAME}" + f":{self.POSTGRES_DATABASE_PORT}/{self.POSTGRES_DATABASE_DB}" ) settings = Settings() - diff --git a/app/main.py b/app/main.py index a394083..e3af335 100644 --- a/app/main.py +++ b/app/main.py @@ -1,13 +1,30 @@ +import logging +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates + from app.api.v1.api import api_router from app.api.v1.web import web_router +from app.config import settings +from migrations.runner import MigrationRunner + +@asynccontextmanager +async def lifespan(app: FastAPI): + logging.info(f"\n\n{settings.get_postgres_database_url()}") + runner = MigrationRunner(settings.get_postgres_database_url()) + await runner.run_migrations() + yield + + +# TODO: (#ToLearn) Почитать про lifespan в FastAPI приложениях app = FastAPI( title="Fitness Parser API", description="API for parsing fitness training data from various sources", version="0.1.0", + lifespan=lifespan ) app.mount("/static", StaticFiles(directory="app/static"), name="static") diff --git a/compose.yaml b/compose.yaml index 96ec801..ee6daa9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -11,21 +11,29 @@ services: - ./app:/app/app:ro - ./data:/app/data:ro - ./tests:/app/tests:ro + - ./migrations:/app/migrations:ro + env_file: + - .env environment: - - PYTHONPATH=/app + - PYTHONPATH=${PYTHONPATH} command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /app/app + depends_on: + postgres: + condition: service_healthy postgres: - image: postgres:15 + image: postgres:15-alpine + env_file: + - .env environment: - - POSTGRES_DB=fitness_db - - POSTGRES_USER=fitness_user - - POSTGRES_PASSWORD=fitness_password + - POSTGRES_DB=${POSTGRES_DATABASE_DB} + - POSTGRES_USER=${POSTGRES_DATABASE_USER} + - POSTGRES_PASSWORD=${POSTGRES_DATABASE_PASSWORD} ports: - - "5432:5432" + - "${POSTGRES_DATABASE_PORT}:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U fitness_user -d fitness_db"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_DATABASE_USER} -d ${POSTGRES_DATABASE_DB}"] interval: 10s timeout: 5s retries: 5 diff --git a/migrations/runner.py b/migrations/runner.py index f774d5e..0f70323 100644 --- a/migrations/runner.py +++ b/migrations/runner.py @@ -10,6 +10,7 @@ class MigrationRunner: def __init__(self, database_url: str) -> None: self.engine = create_async_engine(database_url) self.migrations_dir = Path("migrations/sql") + logging.error(f"{database_url}") async def get_applied_migrations(self) -> set: """Get list of applied migrations""" @@ -33,6 +34,7 @@ class MigrationRunner: async def run_migrations(self): """Run all unapplied migrations""" applied = await self.get_applied_migrations() + logging.info(f"Applied migrations: {applied}") # Getting all sql files from migrations_dir # TODO: (#ToLearn) Read about Path.glob function @@ -47,3 +49,22 @@ class MigrationRunner: await self._apply_migration(migration_file, migration_name) else: logging.info(f"Skipping migration: {migration_name} (already applied)") + + async def _apply_migration(self, migration_file: Path, migration_name: str): + """Apply migrations from migrations/sql folder""" + with open(migration_file, 'r', encoding='utf-8') as f: + sql_content = f.read() + + async with self.engine.begin() as conn: + try: + statements = [s.strip() for s in sql_content.split(';') if s.strip()] + + for statement in statements: + if statement: + await conn.execute(text(statement)) + + logging.info(f"Migration {migration_name} applied successfully") + + except Exception as e: + logging.error(f"Error applying migration {migration_name}: {e}") + raise diff --git a/migrations/0001_initial.sql b/migrations/sql/0001_initial.sql similarity index 100% rename from migrations/0001_initial.sql rename to migrations/sql/0001_initial.sql diff --git a/pyproject.toml b/pyproject.toml index 9d76db3..1c971ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "jinja2>=3.1.0", "python-multipart>=0.0.6", "sqlalchemy>=2.0.43", + "asyncpg>=0.30.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 01f8909..2020598 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" [[package]] @@ -24,6 +24,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] +[[package]] +name = "asyncpg" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746, upload-time = "2024-10-20T00:30:41.127Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373, upload-time = "2024-10-20T00:29:55.165Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745, upload-time = "2024-10-20T00:29:57.14Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103, upload-time = "2024-10-20T00:29:58.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471, upload-time = "2024-10-20T00:30:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253, upload-time = "2024-10-20T00:30:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720, upload-time = "2024-10-20T00:30:04.501Z" }, + { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404, upload-time = "2024-10-20T00:30:06.537Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623, upload-time = "2024-10-20T00:30:09.024Z" }, +] + [[package]] name = "click" version = "8.2.1" @@ -50,6 +66,7 @@ name = "f1tness-parser" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "asyncpg" }, { name = "fastapi" }, { name = "jinja2" }, { name = "pydantic" }, @@ -70,6 +87,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "asyncpg", specifier = ">=0.30.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "jinja2", specifier = ">=3.1.0" }, { name = "pydantic", specifier = ">=2.11.7" },