"""Database engine and session setup. Connection precedence: 1. POSTGRES_USER + POSTGRES_PASSWORD + POSTGRES_DB (preferred) — the URL is built with SQLAlchemy's URL.create(), which encodes the password for you, so passwords with special characters (@ ! # : / …) need NO manual escaping. Host/port default to POSTGRES_HOST=db / POSTGRES_PORT=5432. 2. DATABASE_URL — a full SQLAlchemy URL, if you'd rather supply one directly (you must URL-encode any special characters in the password yourself). 3. Neither set → a local SQLite file, so the API runs anywhere without Postgres. The schema is identical either way (SQLAlchemy handles dialect differences). """ import os from sqlalchemy import create_engine, event, URL from sqlalchemy.orm import sessionmaker, DeclarativeBase # Load a local .env if present (dev convenience). try: from dotenv import load_dotenv load_dotenv() except Exception: pass def _resolve_url(): user = os.getenv("POSTGRES_USER") pw = os.getenv("POSTGRES_PASSWORD") dbname = os.getenv("POSTGRES_DB") if user and pw and dbname: # Build from components — password is encoded automatically. return URL.create( "postgresql+psycopg", username=user, password=pw, host=os.getenv("POSTGRES_HOST", "db"), port=int(os.getenv("POSTGRES_PORT", "5432")), database=dbname, ) return os.getenv("DATABASE_URL") or "sqlite:///./wpsuite.db" DATABASE_URL = _resolve_url() # SQLite needs this flag from FastAPI's threadpool; Postgres ignores it. _is_sqlite = isinstance(DATABASE_URL, str) and DATABASE_URL.startswith("sqlite") connect_args = {"check_same_thread": False} if _is_sqlite else {} engine = create_engine(DATABASE_URL, connect_args=connect_args, pool_pre_ping=True, future=True) if _is_sqlite: # SQLite ships with foreign keys DISABLED and the pragma is per-connection, so # without this every `ondelete="CASCADE"` in models.py is silently a no-op on a # dev database while working correctly on Postgres. That divergence is worse than # it sounds: deleting a project left its SOPs, work packages and membership rows # behind as orphans pointing at an id that no longer exists, and deleting a user # left their project_members rows — and the smoke test's cascade assertion failed # on dev while passing in production, which is the exact failure that makes a # smoke test worth ignoring. # # Registered on the engine, not a session, because the pragma has to be set on # each new DBAPI connection as the pool creates it. @event.listens_for(engine, "connect") def _sqlite_enforce_foreign_keys(dbapi_connection, _record): cur = dbapi_connection.cursor() cur.execute("PRAGMA foreign_keys=ON") cur.close() SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) class Base(DeclarativeBase): pass def get_db(): """FastAPI dependency that yields a session and always closes it.""" db = SessionLocal() try: yield db finally: db.close()