294 lines
8.3 KiB
Python
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
|