| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- """
- app/main.py
- ───────────
- FastAPI application bootstrap:
- · CORS middleware (origin whitelist from env)
- · Lifespan handler: DDL table creation + admin seed
- · Auth and cron routers (no auth dependency on their own paths)
- · Protected API router for all CRUD + config endpoints
- """
- from __future__ import annotations
- import logging
- import os
- from contextlib import asynccontextmanager
- from typing import List
- from fastapi import APIRouter, Depends, FastAPI, HTTPException
- from fastapi.middleware.cors import CORSMiddleware
- from sqlalchemy.orm import Session
- from app.auth import get_current_user, router as auth_router, seed_admin
- from app.config import settings
- from app.cron import router as cron_router
- from app.crud import (
- create_client, create_invoice, create_template,
- delete_client, delete_invoice, delete_template,
- get_clients, get_invoices, get_notification_logs, get_templates,
- update_client, update_invoice, update_template,
- )
- from app.database import Base, engine, get_db
- from app.models import (
- ClientCreate, ClientRead, ClientUpdate,
- ConfigEntry,
- InvoiceCreate, InvoiceRead, InvoiceUpdate,
- NotificationLogRead,
- TemplateCreate, TemplateRead, TemplateUpdate,
- )
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
- )
- logger = logging.getLogger(__name__)
- # ─────────────────────────────────────────────────────────────────────────────
- # Config endpoint — sensitive-value masking
- # ─────────────────────────────────────────────────────────────────────────────
- _SENSITIVE_TOKENS = {"PASSWORD", "SECRET", "TOKEN", "KEY", "DATABASE_URL", "DSN", "PWD", "PASS"}
- def _is_sensitive(key: str) -> bool:
- ku = key.upper()
- return any(tok in ku for tok in _SENSITIVE_TOKENS)
- # ─────────────────────────────────────────────────────────────────────────────
- # Lifespan
- # ─────────────────────────────────────────────────────────────────────────────
- @asynccontextmanager
- async def lifespan(app: FastAPI):
- # DDL: create all tables if they don't exist yet
- Base.metadata.create_all(bind=engine)
- # Seed the default admin account on first boot
- db = next(get_db())
- try:
- seed_admin(db)
- finally:
- db.close()
- logger.info("✅ PMDI backend ready.")
- yield
- logger.info("🛑 PMDI backend shutting down.")
- # ─────────────────────────────────────────────────────────────────────────────
- # Application
- # ─────────────────────────────────────────────────────────────────────────────
- app = FastAPI(
- title="PMDI – Pay My Damn Invoice",
- description="Internal invoice notification dashboard API.",
- version="1.0.0",
- docs_url="/api/docs",
- redoc_url="/api/redoc",
- openapi_url="/api/openapi.json",
- lifespan=lifespan,
- )
- # ─────────────────────────────────────────────────────────────────────────────
- # CORS middleware — strict origin whitelist
- # ─────────────────────────────────────────────────────────────────────────────
- _origins = [o.strip() for o in settings.ALLOWED_ORIGINS.split(",") if o.strip()]
- app.add_middleware(
- CORSMiddleware,
- allow_origins=_origins,
- allow_credentials=True,
- allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
- allow_headers=["Authorization", "Content-Type"],
- expose_headers=["Content-Length"],
- )
- # ─────────────────────────────────────────────────────────────────────────────
- # Unauthenticated routers
- # ─────────────────────────────────────────────────────────────────────────────
- app.include_router(auth_router) # /api/auth/* — handles its own auth internally
- app.include_router(cron_router) # /api/cron/* — guarded by CRON_SECRET_TOKEN
- # ─────────────────────────────────────────────────────────────────────────────
- # Protected API router (requires valid JWT for every sub-route)
- # ─────────────────────────────────────────────────────────────────────────────
- protected = APIRouter(
- prefix="/api",
- dependencies=[Depends(get_current_user)],
- )
- # ── Health ────────────────────────────────────────────────────────────────────
- @app.get("/api/health", tags=["system"])
- def health():
- return {"status": "ok", "service": "pmdi-backend", "version": "1.0.0"}
- # ── Config ────────────────────────────────────────────────────────────────────
- @protected.get("/config", response_model=List[ConfigEntry], tags=["config"])
- def get_config():
- """
- Return all container environment variables.
- Values whose key names contain PASSWORD / SECRET / TOKEN / KEY / DATABASE_URL
- are automatically replaced with '••••••••'.
- """
- entries = []
- for key, value in sorted(os.environ.items()):
- masked = _is_sensitive(key)
- entries.append(ConfigEntry(
- key=key,
- value="••••••••" if masked else value,
- masked=masked,
- ))
- return entries
- # ══════════════════════════════════════════════════════════════════════════════
- # Clients
- # ══════════════════════════════════════════════════════════════════════════════
- @protected.get("/clients", response_model=List[ClientRead], tags=["clients"])
- def list_clients(db: Session = Depends(get_db)):
- return get_clients(db)
- @protected.post("/clients", response_model=ClientRead, status_code=201, tags=["clients"])
- def add_client(data: ClientCreate, db: Session = Depends(get_db)):
- return create_client(db, data)
- @protected.put("/clients/{client_id}", response_model=ClientRead, tags=["clients"])
- def edit_client(client_id: int, data: ClientUpdate, db: Session = Depends(get_db)):
- obj = update_client(db, client_id, data)
- if not obj:
- raise HTTPException(404, "Client not found.")
- return obj
- @protected.delete("/clients/{client_id}", status_code=204, tags=["clients"])
- def remove_client(client_id: int, db: Session = Depends(get_db)):
- if not delete_client(db, client_id):
- raise HTTPException(404, "Client not found.")
- # ══════════════════════════════════════════════════════════════════════════════
- # Email Templates
- # ══════════════════════════════════════════════════════════════════════════════
- @protected.get("/templates", response_model=List[TemplateRead], tags=["templates"])
- def list_templates(db: Session = Depends(get_db)):
- return get_templates(db)
- @protected.post("/templates", response_model=TemplateRead, status_code=201, tags=["templates"])
- def add_template(data: TemplateCreate, db: Session = Depends(get_db)):
- return create_template(db, data)
- @protected.put("/templates/{template_id}", response_model=TemplateRead, tags=["templates"])
- def edit_template(template_id: int, data: TemplateUpdate, db: Session = Depends(get_db)):
- obj = update_template(db, template_id, data)
- if not obj:
- raise HTTPException(404, "Template not found.")
- return obj
- @protected.delete("/templates/{template_id}", status_code=204, tags=["templates"])
- def remove_template(template_id: int, db: Session = Depends(get_db)):
- if not delete_template(db, template_id):
- raise HTTPException(404, "Template not found.")
- # ══════════════════════════════════════════════════════════════════════════════
- # Invoices
- # ══════════════════════════════════════════════════════════════════════════════
- @protected.get("/invoices", response_model=List[InvoiceRead], tags=["invoices"])
- def list_invoices(db: Session = Depends(get_db)):
- return get_invoices(db)
- @protected.post("/invoices", response_model=InvoiceRead, status_code=201, tags=["invoices"])
- def add_invoice(data: InvoiceCreate, db: Session = Depends(get_db)):
- return create_invoice(db, data)
- @protected.put("/invoices/{invoice_id}", response_model=InvoiceRead, tags=["invoices"])
- def edit_invoice(invoice_id: int, data: InvoiceUpdate, db: Session = Depends(get_db)):
- obj = update_invoice(db, invoice_id, data)
- if not obj:
- raise HTTPException(404, "Invoice not found.")
- return obj
- @protected.delete("/invoices/{invoice_id}", status_code=204, tags=["invoices"])
- def remove_invoice(invoice_id: int, db: Session = Depends(get_db)):
- if not delete_invoice(db, invoice_id):
- raise HTTPException(404, "Invoice not found.")
- # ══════════════════════════════════════════════════════════════════════════════
- # Notification Logs (read-only)
- # ══════════════════════════════════════════════════════════════════════════════
- @protected.get("/notifications", response_model=List[NotificationLogRead], tags=["notifications"])
- def list_notifications(db: Session = Depends(get_db)):
- return get_notification_logs(db)
- # Mount the protected router
- app.include_router(protected)
|