Files
foodlinkk-command-center/cockpit/app/main.py
T

294 lines
8.3 KiB
Python

import asyncio
import json
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.responses import Response
from app.db import close_pool, fetch_all, init_pool
from app.routes import (
retail,
agents,
analytics,
documents,
api,
clients,
dashboard,
deals,
herman,
studio,
marketing,
monitor,
products,
reports,
suppliers,
voice,
settings,
browser,
hermes,
beurs,
packaging,
ops,
ops_api,
revenue_cockpit,
export_intel,
)
from app.routes.revenue_cockpit import api as revenue_cockpit_api
from app.routes.admin_api import admin_router, ai_router, herman_api, voice_api
from app.routes.settings_api import settings_router
from app.routes import auth_routes
from app import auth_users
from app.config import settings as app_settings
from app.routes.agents_api import router as agents_api_router
from app.routes.marketing_api import router as marketing_api_router
from app.routes.projects_api import router as projects_api_router
BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
class NoCacheStaticFiles(StaticFiles):
"""Serve static assets without browser/SW long-lived caching."""
async def get_response(self, path: str, scope) -> Response:
response = await super().get_response(path, scope)
if path.endswith((".css", ".js", ".html")) or "/css/" in path or "/js/" in path:
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
app = FastAPI(title="Foodlinkk Command Center", version="2.5.0")
PUBLIC_PREFIXES = (
"/login",
"/logout",
"/static/",
"/sw.js",
"/manifest.webmanifest",
"/static/manifest.json",
"/api/auth/login",
)
PUBLIC_EXACT = {"/favicon.ico", "/robots.txt"}
def _is_public(path: str) -> bool:
if path in PUBLIC_EXACT:
return True
for pref in PUBLIC_PREFIXES:
if path == pref or path.startswith(pref):
return True
return False
class AuthGateMiddleware:
"""Require login; must run *inside* SessionMiddleware."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
from starlette.requests import Request
request = Request(scope, receive=receive)
path = request.url.path
user = None
try:
uid = request.session.get("user_id")
except Exception:
uid = None
if uid:
try:
user = auth_users.get_user_by_id(int(uid))
except Exception:
user = None
if not user or not user.get("is_active"):
try:
request.session.clear()
except Exception:
pass
user = None
request.state.user = user
async def call_next():
await self.app(scope, receive, send)
if _is_public(path):
await self.app(scope, receive, send)
return
if not user:
if path.startswith("/api/") or path.startswith("/ws"):
response = JSONResponse({"detail": "Niet ingelogd"}, status_code=401)
else:
nxt = path
if request.url.query:
nxt = f"{path}?{request.url.query}"
from urllib.parse import quote
response = RedirectResponse(f"/login?next={quote(nxt)}", status_code=303)
await response(scope, receive, send)
return
if (
not path.startswith("/api/")
and not path.startswith("/ws")
and not path.startswith("/static")
and not auth_users.path_allowed(user, path)
):
response = RedirectResponse("/", status_code=303)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
# Order: first added = innermost. Session must be outermost so request.session works.
app.add_middleware(AuthGateMiddleware)
app.add_middleware(
SessionMiddleware,
secret_key=app_settings.SESSION_SECRET,
session_cookie="flk_session",
same_site="lax",
https_only=False,
max_age=app_settings.SESSION_MAX_AGE,
)
@app.api_route("/sw.js", methods=["GET", "HEAD"], include_in_schema=False)
async def service_worker():
"""Root-scoped service worker for installable PWA (Android + iOS Safari)."""
sw_path = BASE_DIR / "static" / "sw.js"
return FileResponse(
sw_path,
media_type="application/javascript; charset=utf-8",
headers={
"Service-Worker-Allowed": "/",
"Cache-Control": "no-cache, no-store, must-revalidate",
},
)
@app.api_route("/manifest.webmanifest", methods=["GET", "HEAD"], include_in_schema=False)
async def web_manifest():
man_path = BASE_DIR / "static" / "manifest.json"
return FileResponse(
man_path,
media_type="application/manifest+json",
headers={"Cache-Control": "no-cache"},
)
app.mount("/static", NoCacheStaticFiles(directory=str(BASE_DIR / "static")), name="static")
for r in (
dashboard.router,
beurs.router,
agents.router,
marketing.router,
retail.router,
clients.router,
deals.router,
products.router,
suppliers.router,
monitor.router,
analytics.router,
documents.router,
reports.router,
voice.router,
settings.router,
auth_routes.router,
browser.router,
herman.router,
studio.router,
hermes.router,
packaging.router,
ops.router,
revenue_cockpit.router,
export_intel.router,
revenue_cockpit_api,
api.router,
ops_api.router,
admin_router,
ai_router,
herman_api,
voice_api,
settings_router,
agents_api_router,
marketing_api_router,
projects_api_router,
auth_routes.api_router,
auth_routes.users_api,
):
app.include_router(r)
@app.on_event("startup")
def on_startup() -> None:
init_pool()
try:
auth_users.ensure_admin_seed()
except Exception as e:
print(f"auth seed warning: {e}")
@app.on_event("shutdown")
def on_shutdown() -> None:
close_pool()
@app.websocket("/ws/agents")
async def ws_agents(websocket: WebSocket) -> None:
await websocket.accept()
last_payload: str | None = None
try:
while True:
try:
rows = fetch_all(
"""SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT 50"""
)
for row in rows:
if row.get("created_at") is not None:
row["created_at"] = row["created_at"].isoformat()
payload = json.dumps({"events": rows})
except Exception as exc:
payload = json.dumps({"error": str(exc), "events": []})
if payload != last_payload:
await websocket.send_text(payload)
last_payload = payload
await asyncio.sleep(3)
except WebSocketDisconnect:
return
@app.websocket("/ws/feed")
async def ws_feed(websocket: WebSocket) -> None:
await websocket.accept()
try:
while True:
snapshot = {"type": "heartbeat", "events": []}
try:
snapshot["events"] = fetch_all(
"SELECT agent_name, event_type, title, status, created_at FROM agent_events ORDER BY created_at DESC LIMIT 15"
)
for row in snapshot["events"]:
if row.get("created_at"):
row["created_at"] = row["created_at"].isoformat()
except Exception as exc:
snapshot["error"] = str(exc)
await websocket.send_text(json.dumps(snapshot))
await asyncio.sleep(5)
except WebSocketDisconnect:
return