main.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. """
  2. app/main.py
  3. ───────────
  4. FastAPI application bootstrap:
  5. · CORS middleware (origin whitelist from env)
  6. · Lifespan handler: DDL table creation + admin seed
  7. · Auth and cron routers (no auth dependency on their own paths)
  8. · Protected API router for all CRUD + config endpoints
  9. """
  10. from __future__ import annotations
  11. import logging
  12. import os
  13. from contextlib import asynccontextmanager
  14. from typing import List
  15. from fastapi import APIRouter, Depends, FastAPI, HTTPException
  16. from fastapi.middleware.cors import CORSMiddleware
  17. from sqlalchemy.orm import Session
  18. from app.auth import get_current_user, router as auth_router, seed_admin
  19. from app.config import settings
  20. from app.cron import router as cron_router
  21. from app.crud import (
  22. create_client, create_invoice, create_template,
  23. delete_client, delete_invoice, delete_template,
  24. get_clients, get_invoices, get_notification_logs, get_templates,
  25. update_client, update_invoice, update_template,
  26. )
  27. from app.database import Base, engine, get_db
  28. from app.models import (
  29. ClientCreate, ClientRead, ClientUpdate,
  30. ConfigEntry,
  31. InvoiceCreate, InvoiceRead, InvoiceUpdate,
  32. NotificationLogRead,
  33. TemplateCreate, TemplateRead, TemplateUpdate,
  34. )
  35. logging.basicConfig(
  36. level=logging.INFO,
  37. format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
  38. )
  39. logger = logging.getLogger(__name__)
  40. # ─────────────────────────────────────────────────────────────────────────────
  41. # Config endpoint — sensitive-value masking
  42. # ─────────────────────────────────────────────────────────────────────────────
  43. _SENSITIVE_TOKENS = {"PASSWORD", "SECRET", "TOKEN", "KEY", "DATABASE_URL", "DSN", "PWD", "PASS"}
  44. def _is_sensitive(key: str) -> bool:
  45. ku = key.upper()
  46. return any(tok in ku for tok in _SENSITIVE_TOKENS)
  47. # ─────────────────────────────────────────────────────────────────────────────
  48. # Lifespan
  49. # ─────────────────────────────────────────────────────────────────────────────
  50. @asynccontextmanager
  51. async def lifespan(app: FastAPI):
  52. # DDL: create all tables if they don't exist yet
  53. Base.metadata.create_all(bind=engine)
  54. # Seed the default admin account on first boot
  55. db = next(get_db())
  56. try:
  57. seed_admin(db)
  58. finally:
  59. db.close()
  60. logger.info("✅ PMDI backend ready.")
  61. yield
  62. logger.info("🛑 PMDI backend shutting down.")
  63. # ─────────────────────────────────────────────────────────────────────────────
  64. # Application
  65. # ─────────────────────────────────────────────────────────────────────────────
  66. app = FastAPI(
  67. title="PMDI – Pay My Damn Invoice",
  68. description="Internal invoice notification dashboard API.",
  69. version="1.0.0",
  70. docs_url="/api/docs",
  71. redoc_url="/api/redoc",
  72. openapi_url="/api/openapi.json",
  73. lifespan=lifespan,
  74. )
  75. # ─────────────────────────────────────────────────────────────────────────────
  76. # CORS middleware — strict origin whitelist
  77. # ─────────────────────────────────────────────────────────────────────────────
  78. _origins = [o.strip() for o in settings.ALLOWED_ORIGINS.split(",") if o.strip()]
  79. app.add_middleware(
  80. CORSMiddleware,
  81. allow_origins=_origins,
  82. allow_credentials=True,
  83. allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
  84. allow_headers=["Authorization", "Content-Type"],
  85. expose_headers=["Content-Length"],
  86. )
  87. # ─────────────────────────────────────────────────────────────────────────────
  88. # Unauthenticated routers
  89. # ─────────────────────────────────────────────────────────────────────────────
  90. app.include_router(auth_router) # /api/auth/* — handles its own auth internally
  91. app.include_router(cron_router) # /api/cron/* — guarded by CRON_SECRET_TOKEN
  92. # ─────────────────────────────────────────────────────────────────────────────
  93. # Protected API router (requires valid JWT for every sub-route)
  94. # ─────────────────────────────────────────────────────────────────────────────
  95. protected = APIRouter(
  96. prefix="/api",
  97. dependencies=[Depends(get_current_user)],
  98. )
  99. # ── Health ────────────────────────────────────────────────────────────────────
  100. @app.get("/api/health", tags=["system"])
  101. def health():
  102. return {"status": "ok", "service": "pmdi-backend", "version": "1.0.0"}
  103. # ── Config ────────────────────────────────────────────────────────────────────
  104. @protected.get("/config", response_model=List[ConfigEntry], tags=["config"])
  105. def get_config():
  106. """
  107. Return all container environment variables.
  108. Values whose key names contain PASSWORD / SECRET / TOKEN / KEY / DATABASE_URL
  109. are automatically replaced with '••••••••'.
  110. """
  111. entries = []
  112. for key, value in sorted(os.environ.items()):
  113. masked = _is_sensitive(key)
  114. entries.append(ConfigEntry(
  115. key=key,
  116. value="••••••••" if masked else value,
  117. masked=masked,
  118. ))
  119. return entries
  120. # ══════════════════════════════════════════════════════════════════════════════
  121. # Clients
  122. # ══════════════════════════════════════════════════════════════════════════════
  123. @protected.get("/clients", response_model=List[ClientRead], tags=["clients"])
  124. def list_clients(db: Session = Depends(get_db)):
  125. return get_clients(db)
  126. @protected.post("/clients", response_model=ClientRead, status_code=201, tags=["clients"])
  127. def add_client(data: ClientCreate, db: Session = Depends(get_db)):
  128. return create_client(db, data)
  129. @protected.put("/clients/{client_id}", response_model=ClientRead, tags=["clients"])
  130. def edit_client(client_id: int, data: ClientUpdate, db: Session = Depends(get_db)):
  131. obj = update_client(db, client_id, data)
  132. if not obj:
  133. raise HTTPException(404, "Client not found.")
  134. return obj
  135. @protected.delete("/clients/{client_id}", status_code=204, tags=["clients"])
  136. def remove_client(client_id: int, db: Session = Depends(get_db)):
  137. if not delete_client(db, client_id):
  138. raise HTTPException(404, "Client not found.")
  139. # ══════════════════════════════════════════════════════════════════════════════
  140. # Email Templates
  141. # ══════════════════════════════════════════════════════════════════════════════
  142. @protected.get("/templates", response_model=List[TemplateRead], tags=["templates"])
  143. def list_templates(db: Session = Depends(get_db)):
  144. return get_templates(db)
  145. @protected.post("/templates", response_model=TemplateRead, status_code=201, tags=["templates"])
  146. def add_template(data: TemplateCreate, db: Session = Depends(get_db)):
  147. return create_template(db, data)
  148. @protected.put("/templates/{template_id}", response_model=TemplateRead, tags=["templates"])
  149. def edit_template(template_id: int, data: TemplateUpdate, db: Session = Depends(get_db)):
  150. obj = update_template(db, template_id, data)
  151. if not obj:
  152. raise HTTPException(404, "Template not found.")
  153. return obj
  154. @protected.delete("/templates/{template_id}", status_code=204, tags=["templates"])
  155. def remove_template(template_id: int, db: Session = Depends(get_db)):
  156. if not delete_template(db, template_id):
  157. raise HTTPException(404, "Template not found.")
  158. # ══════════════════════════════════════════════════════════════════════════════
  159. # Invoices
  160. # ══════════════════════════════════════════════════════════════════════════════
  161. @protected.get("/invoices", response_model=List[InvoiceRead], tags=["invoices"])
  162. def list_invoices(db: Session = Depends(get_db)):
  163. return get_invoices(db)
  164. @protected.post("/invoices", response_model=InvoiceRead, status_code=201, tags=["invoices"])
  165. def add_invoice(data: InvoiceCreate, db: Session = Depends(get_db)):
  166. return create_invoice(db, data)
  167. @protected.put("/invoices/{invoice_id}", response_model=InvoiceRead, tags=["invoices"])
  168. def edit_invoice(invoice_id: int, data: InvoiceUpdate, db: Session = Depends(get_db)):
  169. obj = update_invoice(db, invoice_id, data)
  170. if not obj:
  171. raise HTTPException(404, "Invoice not found.")
  172. return obj
  173. @protected.delete("/invoices/{invoice_id}", status_code=204, tags=["invoices"])
  174. def remove_invoice(invoice_id: int, db: Session = Depends(get_db)):
  175. if not delete_invoice(db, invoice_id):
  176. raise HTTPException(404, "Invoice not found.")
  177. # ══════════════════════════════════════════════════════════════════════════════
  178. # Notification Logs (read-only)
  179. # ══════════════════════════════════════════════════════════════════════════════
  180. @protected.get("/notifications", response_model=List[NotificationLogRead], tags=["notifications"])
  181. def list_notifications(db: Session = Depends(get_db)):
  182. return get_notification_logs(db)
  183. # Mount the protected router
  184. app.include_router(protected)