"""The ASGI application. On start it creates the schema, seeds the settings from the environment, starts the background jobs and the Telegram bot, and — when a built frontend is present in `static/` — serves the web UI from the same origin, which is what the Docker image does. """ from __future__ import annotations import logging import os from contextlib import asynccontextmanager from pathlib import Path from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from app import __version__ from app.api import api_router from app.api.deps import require_token from app.config import env_settings from app.db import init_db, session_scope from app.services import scheduler, settings_service, telegram_bot logging.basicConfig( level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", ) log = logging.getLogger("kadence") STATIC_DIR = Path(__file__).resolve().parent.parent / "static" @asynccontextmanager async def lifespan(app: FastAPI): init_db() with session_scope() as session: settings = settings_service.load_settings(session) log.info("settings loaded (timezone=%s)", settings.timezone) scheduler.start(settings) if settings.notifications.telegram.enabled: telegram_bot.start() # The bot has to come up later too, if Telegram is switched on in the UI. settings_service.on_change( lambda new: telegram_bot.start() if new.notifications.telegram.enabled else None ) try: yield finally: scheduler.shutdown() telegram_bot.shutdown() app = FastAPI( title="Kadence", version=__version__, summary="Pomodoro timer, todo aggregation, blocking policy and daily reports.", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=env_settings().cors_origin_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(api_router, dependencies=[Depends(require_token)]) @app.get("/api/health", tags=["meta"]) def health() -> dict: return {"status": "ok", "version": __version__} @app.get("/api/info", tags=["meta"]) def info() -> dict: """What a client needs to know before it has a token: who we are, what is on.""" env = env_settings() return { "version": __version__, "auth_required": bool(env.api_token), "scheduler": env.scheduler_enabled, "jobs": scheduler.describe(), } # ---- the web UI, when it has been built into ./static ---------------------- if STATIC_DIR.is_dir(): app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets") @app.get("/{path:path}", include_in_schema=False) def spa(path: str): """Serve the single-page app, letting the client router own the URL.""" candidate = STATIC_DIR / path if path and candidate.is_file(): return FileResponse(candidate) index = STATIC_DIR / "index.html" if index.is_file(): return FileResponse(index) return JSONResponse({"detail": "Not found"}, status_code=404)