Merge pull request #48 from ineszang/feat/db-timescaledb
feat(db): PostgreSQL 17 + TimescaleDB et connexion backend
This commit is contained in:
@@ -3,4 +3,4 @@ APP_DEBUG=true
|
||||
APP_LOG_LEVEL=INFO
|
||||
APP_SECRET_KEY=change_me
|
||||
APP_CORS_ORIGINS=http://localhost:4200
|
||||
DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5432/enervision
|
||||
DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision
|
||||
|
||||
+13
-1
@@ -22,6 +22,9 @@ uv sync --all-groups
|
||||
`APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par defaut : l'application refuse
|
||||
de demarrer sans elles.
|
||||
|
||||
`DATABASE_URL` pointe sur `localhost:5433`, le port publie par le service `db` du
|
||||
`docker-compose.yml` racine. Demarrer la base depuis la racine avec `make db-up`.
|
||||
|
||||
## Commandes
|
||||
|
||||
Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`,
|
||||
@@ -35,8 +38,13 @@ uv run ruff check . # lint
|
||||
uv run ruff format . # format
|
||||
uv run mypy app # typage strict
|
||||
uv run pytest # tests + couverture
|
||||
uv run pytest -m integration # tests exigeant une base joignable
|
||||
```
|
||||
|
||||
`pytest` ecarte par defaut les tests marques `integration`, pour que `make check` reste
|
||||
jouable sans Docker. Ces tests visent la base `enervision_test`, creee par
|
||||
`db/init/110-test-database.sql` au premier demarrage du conteneur.
|
||||
|
||||
L'application est exposee par une factory (`create_app`) et non par un objet module :
|
||||
aucune configuration n'est lue a l'import, ce qui rend les tests et les migrations
|
||||
independants de l'environnement.
|
||||
@@ -73,7 +81,7 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
|
||||
| Route | Role |
|
||||
|------------------------|-------------------------------------------------|
|
||||
| `/api/v1/health/live` | Sonde de vivacite, aucune dependance externe |
|
||||
| `/api/v1/health/ready` | Sonde de disponibilite, verifie la base |
|
||||
| `/api/v1/health/ready` | Sonde de disponibilite, verifie la base et TimescaleDB |
|
||||
| `/metrics` | Metriques au format Prometheus |
|
||||
| `/docs`, `/openapi.json` | Documentation, desactivee quand `APP_ENV=prod` |
|
||||
|
||||
@@ -86,6 +94,10 @@ uv run alembic upgrade head
|
||||
|
||||
L'URL de connexion vient de `DATABASE_URL`, pas de `alembic.ini`.
|
||||
|
||||
La premiere revision ne cree aucune table : elle refuse de s'appliquer si l'extension
|
||||
TimescaleDB manque, ce qui arrive quand `db/init` n'a pas ete joue. Le DDL propre a
|
||||
TimescaleDB qui ne depend pas du schema applicatif vit dans `db/`, pas ici.
|
||||
|
||||
## Image Docker
|
||||
|
||||
Build multi-stage, dependances resolues par uv depuis `uv.lock`, execution sous un
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""socle garde extension timescaledb
|
||||
|
||||
Revision ID: 5353c0e4f094
|
||||
Revises:
|
||||
Create Date: 2026-09-14 14:17:17.556764
|
||||
|
||||
Premiere revision du schema applicatif. Elle ne cree aucune table : elle etablit
|
||||
alembic_version et refuse de s'appliquer sur une base ou l'extension TimescaleDB
|
||||
manque, cas qui se produit quand db/init n'a pas ete joue.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "5353c0e4f094"
|
||||
down_revision: str | Sequence[str] | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
GARDE_EXTENSION = """
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
|
||||
RAISE EXCEPTION 'extension timescaledb absente, voir db/init et db/README.md';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(GARDE_EXTENSION)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -9,6 +9,8 @@ from app.schemas.health import LivenessStatus, ReadinessStatus
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'")
|
||||
|
||||
|
||||
@router.get("/live", summary="Sonde de vivacite")
|
||||
async def liveness(settings: SettingsDep) -> LivenessStatus:
|
||||
@@ -23,11 +25,19 @@ async def liveness(settings: SettingsDep) -> LivenessStatus:
|
||||
@router.get("/ready", summary="Sonde de disponibilite")
|
||||
async def readiness(session: SessionDep) -> ReadinessStatus:
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
|
||||
except SQLAlchemyError, OSError:
|
||||
logger.exception("Base de donnees injoignable")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Base de donnees injoignable",
|
||||
) from None
|
||||
return ReadinessStatus(status="ready", database="reachable")
|
||||
|
||||
if version is None:
|
||||
logger.error("Extension TimescaleDB absente de la base")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Extension TimescaleDB absente",
|
||||
)
|
||||
|
||||
return ReadinessStatus(status="ready", database="reachable", timescaledb=version)
|
||||
|
||||
@@ -13,3 +13,4 @@ class LivenessStatus(BaseModel):
|
||||
class ReadinessStatus(BaseModel):
|
||||
status: Literal["ready"]
|
||||
database: Literal["reachable"]
|
||||
timescaledb: str
|
||||
|
||||
@@ -79,7 +79,8 @@ disallow_untyped_defs = false
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
addopts = "-q --strict-markers --cov=app --cov-report=term-missing"
|
||||
addopts = "-q --strict-markers -m 'not integration' --cov=app --cov-report=term-missing"
|
||||
markers = ["integration: requiert une base PostgreSQL joignable, hors `make test`"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
|
||||
@@ -20,6 +20,44 @@ async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None:
|
||||
}
|
||||
|
||||
|
||||
async def test_readiness_reports_the_timescaledb_version(app: FastAPI, client: AsyncClient) -> None:
|
||||
class ReadySession:
|
||||
async def scalar(self, *_: object, **__: object) -> str:
|
||||
return "2.22.1"
|
||||
|
||||
async def override() -> AsyncIterator[ReadySession]:
|
||||
yield ReadySession()
|
||||
|
||||
app.dependency_overrides[get_session] = override
|
||||
|
||||
response = await client.get("/api/v1/health/ready")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"status": "ready",
|
||||
"database": "reachable",
|
||||
"timescaledb": "2.22.1",
|
||||
}
|
||||
|
||||
|
||||
async def test_readiness_returns_503_when_the_extension_is_missing(
|
||||
app: FastAPI, client: AsyncClient
|
||||
) -> None:
|
||||
class SessionWithoutExtension:
|
||||
async def scalar(self, *_: object, **__: object) -> None:
|
||||
return None
|
||||
|
||||
async def override() -> AsyncIterator[SessionWithoutExtension]:
|
||||
yield SessionWithoutExtension()
|
||||
|
||||
app.dependency_overrides[get_session] = override
|
||||
|
||||
response = await client.get("/api/v1/health/ready")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "Extension TimescaleDB absente"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failure",
|
||||
[
|
||||
@@ -32,7 +70,7 @@ async def test_readiness_returns_503_when_database_is_unreachable(
|
||||
app: FastAPI, client: AsyncClient, failure: Exception
|
||||
) -> None:
|
||||
class UnreachableSession:
|
||||
async def execute(self, *_: object, **__: object) -> None:
|
||||
async def scalar(self, *_: object, **__: object) -> None:
|
||||
raise failure
|
||||
|
||||
async def override() -> AsyncIterator[UnreachableSession]:
|
||||
@@ -49,3 +87,14 @@ async def test_readiness_returns_503_when_database_is_unreachable(
|
||||
@pytest.mark.parametrize("path", ["/openapi.json", "/metrics"])
|
||||
async def test_technical_endpoints_are_served(client: AsyncClient, path: str) -> None:
|
||||
assert (await client.get(path)).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_readiness_reaches_the_real_database(client: AsyncClient) -> None:
|
||||
response = await client.get("/api/v1/health/ready")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["status"] == "ready"
|
||||
assert body["database"] == "reachable"
|
||||
assert body["timescaledb"]
|
||||
|
||||
@@ -6,6 +6,7 @@ from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.session import get_engine, get_session_factory
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@@ -13,13 +14,24 @@ from app.main import create_app
|
||||
def environment() -> Iterator[None]:
|
||||
os.environ.setdefault("APP_SECRET_KEY", "secret-de-test")
|
||||
os.environ.setdefault(
|
||||
"DATABASE_URL", "postgresql+asyncpg://enervision:enervision@localhost:5432/enervision_test"
|
||||
"DATABASE_URL", "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test"
|
||||
)
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# Piege : get_engine est lru_cache et pytest-asyncio ouvre une boucle par test. Sans ce
|
||||
# recyclage, le 2e test touchant vraiment la base heriterait d une boucle morte.
|
||||
@pytest.fixture(autouse=True)
|
||||
async def engine_per_test() -> AsyncIterator[None]:
|
||||
yield
|
||||
if get_engine.cache_info().currsize:
|
||||
await get_engine().dispose()
|
||||
get_engine.cache_clear()
|
||||
get_session_factory.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> FastAPI:
|
||||
return create_app()
|
||||
|
||||
Reference in New Issue
Block a user