107 lines
3.2 KiB
Python
107 lines
3.2 KiB
Python
"""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)
|