Platform bundle: marketing publish, IT ops, packaging, agents mesh.

Volledige Foodlinkk Command Center uitbreiding met social automatisering,
reclamefolder filters, Proxmox monitoring en documentatie.
This commit is contained in:
Aissa
2026-06-09 00:41:27 +00:00
commit 5d60d33db1
212 changed files with 30044 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from app.db import execute, fetch_all
router = APIRouter(prefix="/agents", tags=["agents"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
AGENT_STATUSES = [
{"name": "Herman", "role": "CEO Briefing", "color": "cyan"},
{"name": "Sales", "role": "Pipeline", "color": "purple"},
{"name": "Marketing", "role": "Social & Content", "color": "amber"},
{"name": "Ops", "role": "Operations", "color": "green"},
]
@router.get("")
async def agents_page(request: Request):
active_tab = request.query_params.get("tab", "souls")
if active_tab not in {"souls", "mesh"}:
active_tab = "souls"
events: list = []
try:
events = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events
ORDER BY created_at DESC
LIMIT 100
"""
)
for ev in events:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
events = []
return templates.TemplateResponse(
"agents.html",
{
"request": request,
"page_title": "Agents",
"active_tab": active_tab,
"agents": AGENT_STATUSES,
"events": events,
},
)
@router.post("/approve/{event_id}")
async def approve_event(event_id: int, next_url: str = Form("/")):
try:
execute(
"""
UPDATE agent_events
SET status = 'approved'
WHERE id = %s
""",
(event_id,),
)
except Exception:
pass
return RedirectResponse(url=next_url, status_code=303)
@router.post("/reject/{event_id}")
async def reject_event(event_id: int, next_url: str = Form("/")):
try:
execute(
"""
UPDATE agent_events
SET status = 'rejected'
WHERE id = %s
""",
(event_id,),
)
except Exception:
pass
return RedirectResponse(url=next_url, status_code=303)
+95
View File
@@ -0,0 +1,95 @@
"""Agents API — souls & activity."""
from __future__ import annotations
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.db import fetch_all
from app.services import agent_souls
router = APIRouter(prefix="/api/agents", tags=["agents-api"])
class SoulUpdate(BaseModel):
display_name: Optional[str] = None
role_title: Optional[str] = None
soul_md: Optional[str] = None
responsibilities: Optional[str] = None
is_active: Optional[bool] = None
@router.get("/souls")
def api_list_souls() -> dict[str, Any]:
return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())}
@router.get("/souls/{agent_key}")
def api_get_soul(agent_key: str) -> dict[str, Any]:
soul = agent_souls.get_soul(agent_key)
if not soul:
raise HTTPException(404, "Agent not found")
return {"soul": soul}
@router.put("/souls/{agent_key}")
def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
try:
soul = agent_souls.update_soul(agent_key, **body.model_dump(exclude_none=True))
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
return {"soul": soul}
@router.get("/mesh")
def api_agents_mesh() -> dict[str, Any]:
souls = agent_souls.list_souls()
stats_rows = fetch_all(
"""
SELECT LOWER(agent_name) AS agent_key,
MAX(created_at) AS last_event_at,
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '6 hours') AS events_6h,
COUNT(*) FILTER (
WHERE created_at >= NOW() - INTERVAL '24 hours'
AND status IN ('error', 'rejected')
) AS errors_24h
FROM agent_events
GROUP BY LOWER(agent_name)
"""
)
by_key = {str(r["agent_key"]): dict(r) for r in stats_rows}
nodes: list[dict[str, Any]] = []
for soul in souls:
key = str(soul.get("agent_key") or "").lower()
row = by_key.get(key, {})
events_6h = int(row.get("events_6h") or 0)
errors_24h = int(row.get("errors_24h") or 0)
health = "offline"
if events_6h > 0 and errors_24h == 0:
health = "healthy"
elif events_6h > 0:
health = "warn"
elif int(soul.get("event_count") or 0) > 0:
health = "idle"
node = dict(soul)
node["health"] = health
node["events_6h"] = events_6h
node["errors_24h"] = errors_24h
if row.get("last_event_at") is not None and hasattr(row["last_event_at"], "isoformat"):
node["last_event_at"] = row["last_event_at"].isoformat()
nodes.append(node)
edge_rows = fetch_all(
"""
SELECT LOWER(agent_name) AS source_key, COUNT(*) AS weight
FROM agent_events
WHERE created_at >= NOW() - INTERVAL '24 hours'
AND LOWER(agent_name) <> 'herman'
GROUP BY LOWER(agent_name)
ORDER BY weight DESC
"""
)
edges = [{"source": str(r["source_key"]), "target": "herman", "weight": int(r["weight"])} for r in edge_rows]
return {"nodes": nodes, "edges": edges}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block content %}
<header class="page-header"><h1>Analytics</h1><p class="subtitle page-tip">Pipeline, clients, and agent activity from live database counts.</p></header>
<div class="kpi-grid">
<a href="/deals" class="kpi-card clickable-kpi"><div class="kpi-label">Pipeline EUR</div><div class="kpi-value">€{{ "%.0f"|format(metrics.pipeline) }}</div></a>
<a href="/clients" class="kpi-card purple clickable-kpi"><div class="kpi-label">Clients</div><div class="kpi-value">{{ metrics.clients }}</div></a>
</div>
<div class="grid-2">
<section class="panel"><h2>Deals by stage</h2>
<table class="data-table"><thead><tr><th>Stage</th><th>Count</th><th>Total</th></tr></thead>
<tbody>{% for row in metrics.deals_by_stage %}<tr><td>{{ row.stage }}</td><td>{{ row.cnt }}</td><td>€{{ row.total }}</td></tr>{% endfor %}</tbody></table>
</section>
<section class="panel"><h2>Events by agent</h2>
<table class="data-table"><thead><tr><th>Agent</th><th>Events</th></tr></thead>
<tbody>{% for row in metrics.events_by_agent %}<tr><td>{{ row.agent_name }}</td><td>{{ row.cnt }}</td></tr>{% endfor %}</tbody></table>
</section>
</div>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse
from fastapi.templating import Jinja2Templates
from app.services.analytics_data import collect_analytics
router = APIRouter(prefix="/analytics", tags=["analytics"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("")
async def analytics_page(request: Request):
initial = collect_analytics({})
return templates.TemplateResponse(
"analytics.html",
{"request": request, "page_title": "Analytics", "initial_data": initial},
)
@router.get("/api/data")
async def analytics_api(
chain: Optional[str] = None,
province: Optional[str] = None,
stage: Optional[str] = None,
agent: Optional[str] = None,
days: int = Query(90, ge=7, le=365),
):
filters = {"chain": chain, "province": province, "stage": stage, "agent": agent, "days": days}
filters = {k: v for k, v in filters.items() if v is not None and v != ""}
return JSONResponse(collect_analytics(filters))
+123
View File
@@ -0,0 +1,123 @@
from datetime import date, datetime
from typing import Any, Optional
import httpx
from fastapi import APIRouter, HTTPException
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services.briefing import collect_briefing_data, generate_daily_briefing, serialize_stats
router = APIRouter(prefix="/api", tags=["api"])
def _stats_payload() -> dict[str, Any]:
stats: dict[str, Any] = {
"deals": 0,
"clients": 0,
"pending_approvals": 0,
"pipeline_value": 0,
}
try:
row = fetch_one("SELECT COUNT(*) AS c FROM deals")
stats["deals"] = int(row["c"]) if row else 0
except Exception:
pass
try:
row = fetch_one("SELECT COUNT(*) AS c FROM clients")
stats["clients"] = int(row["c"]) if row else 0
except Exception:
pass
try:
row = fetch_one("SELECT COUNT(*) AS c FROM agent_events WHERE status = 'needs_approval'")
stats["pending_approvals"] = int(row["c"]) if row else 0
except Exception:
pass
try:
row = fetch_one(
"SELECT COALESCE(SUM(value), 0) AS total FROM deals WHERE stage NOT IN ('won', 'lost')"
)
stats["pipeline_value"] = float(row["total"]) if row else 0
except Exception:
pass
return stats
@router.get("/server-time")
async def server_time():
now = datetime.utcnow()
return {"utc": now.isoformat() + "Z", "timezone": "Europe/Amsterdam"}
@router.get("/herman/briefing/stats")
async def herman_briefing_stats():
"""Live stats from DB — always fresh for dashboard panels."""
stats = serialize_stats(collect_briefing_data())
bookmarks = []
bookmark_map = {}
try:
bookmarks = fetch_all(
"""SELECT b.rss_item_id, b.title, b.link, b.feed_name, b.created_at
FROM rss_bookmarks b ORDER BY b.created_at DESC LIMIT 30"""
)
for b in bookmarks:
if b.get("created_at") and hasattr(b["created_at"], "isoformat"):
b["created_at"] = b["created_at"].isoformat()
bookmark_map[b["rss_item_id"]] = True
except Exception:
bookmarks = []
stats["rss_bookmarks"] = bookmarks
stats["rss_bookmark_ids"] = list(bookmark_map.keys())
return {"ok": True, "stats": stats, "bookmarks": bookmarks, "at": datetime.utcnow().isoformat()}
@router.post("/herman/briefing")
async def herman_briefing():
try:
content, stats = await generate_daily_briefing()
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"ok": True, "content": content, "stats": stats, "generated_at": datetime.utcnow().isoformat()}
@router.get("/herman/briefing/latest")
async def herman_briefing_latest():
try:
row = fetch_one(
"SELECT id, content, generated_by, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1"
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
live_stats = serialize_stats(collect_briefing_data())
if not row:
return {"ok": True, "content": None, "stats": live_stats}
if row.get("created_at") and hasattr(row["created_at"], "isoformat"):
row["created_at"] = row["created_at"].isoformat()
return {"ok": True, **row, "stats": live_stats}
@router.get("/live/platform")
async def live_platform(limit: int = 100, agent: Optional[str] = None):
from app.services.platform_live import fetch_platform_events, platform_stats
events = fetch_platform_events(limit=min(limit, 200), agent=agent)
return {"ok": True, "stats": platform_stats(), "events": events}
@router.get("/events")
async def list_events(limit: int = 50):
limit = max(1, min(limit, 200))
try:
rows = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT %s
""",
(limit,),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
for row in rows:
if row.get("created_at"):
row["created_at"] = row["created_at"].isoformat()
return {"events": rows}
+17
View File
@@ -0,0 +1,17 @@
"""Beurs & live market intelligence page."""
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from pathlib import Path
router = APIRouter(tags=["beurs"])
BASE = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE / "templates"))
@router.get("/beurs")
async def beurs_page(request: Request):
tab = request.query_params.get("tab", "beurs")
return templates.TemplateResponse(
"beurs.html",
{"request": request, "page_title": "Beurs & Live Intel", "initial_tab": tab},
)
+71
View File
@@ -0,0 +1,71 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all, fetch_one
router = APIRouter(tags=["browser"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
def _monitor_context() -> dict:
sites, changes, logs = [], [], []
stats = {"active_sites": 0, "changes_24h": 0}
try:
sites = _iso_rows(fetch_all(
"""SELECT id, url, name, last_hash, last_crawled, is_active
FROM monitored_sites ORDER BY last_crawled DESC NULLS LAST LIMIT 100"""
))
row = fetch_one("SELECT COUNT(*) AS c FROM monitored_sites WHERE is_active = TRUE")
stats["active_sites"] = int(row["c"]) if row else 0
row = fetch_one(
"SELECT COUNT(*) AS c FROM page_changes WHERE changed_at > NOW() - interval '24 hours'"
)
stats["changes_24h"] = int(row["c"]) if row else 0
except Exception:
sites = []
try:
changes = _iso_rows(fetch_all(
"""SELECT pc.id, pc.site_id, pc.old_hash, pc.new_hash, pc.changed_at, ms.url, ms.name
FROM page_changes pc JOIN monitored_sites ms ON pc.site_id = ms.id
ORDER BY pc.changed_at DESC LIMIT 50"""
))
except Exception:
changes = []
try:
logs = _iso_rows(fetch_all(
"""SELECT cl.id, cl.site_id, cl.status, cl.message, cl.logged_at, ms.url, ms.name
FROM crawl_logs cl LEFT JOIN monitored_sites ms ON cl.site_id = ms.id
ORDER BY cl.logged_at DESC LIMIT 50"""
))
except Exception:
logs = []
return {"sites": sites, "page_changes": changes, "crawl_logs": logs, "stats": stats}
@router.get("/browser")
async def browser_page(request: Request):
ctx = _monitor_context()
return templates.TemplateResponse(
"browser.html",
{
"request": request,
"page_title": "Browser & Monitor",
"default_url": "https://www.bidfood.nl/webshop/assortiment/mekkafood/_/N-1z10pje/",
"browser_agent_url": "http://10.4.7.18:7790",
"novnc_url": "http://10.4.7.18:6080/vnc.html?autoconnect=true&resize=scale&path=websockify&password=Foodlinkk2026",
"gradio_url": "http://10.4.7.18:7788",
**ctx,
},
)
+55
View File
@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block content %}
<div x-data="clientsCrud()">
<header class="page-header"><h1>Clients</h1>
<p class="subtitle page-tip">Kanban by stage — click a card for details or change stage from the dropdown.</p></header>
<button class="btn btn-primary" @click="openModal()">Add client</button>
<div class="kanban">
{% set stages = ['intake','discovery','proposal','active','churned'] %}
{% for st in stages %}
<div class="kanban-col"><h3>{{ st }}</h3>
{% for c in clients if c.stage == st %}
<div class="kanban-card clickable-row" @click="show({{ c.id }}, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ c.stage|e }}', '{{ (c.notes or '')|e }}')">
<strong>{{ c.name }}</strong><br/><small>{{ c.sector or '' }}</small>
<select class="form-input" @click.stop="" @change="setStage({{ c.id }}, $event.target.value, '{{ c.name|e }}', '{{ (c.email or '')|e }}', '{{ (c.notes or '')|e }}')">
{% for s in stages %}<option value="{{ s }}" {% if c.stage==s %}selected{% endif %}>{{ s }}</option>{% endfor %}
</select>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<div class="modal" :class="{ open: modal }" @click.self="modal=false">
<div class="modal-card"><h3 x-text="form.id ? 'Edit client' : 'New client'"></h3>
<input class="form-input" placeholder="Name" x-model="form.name" />
<input class="form-input" placeholder="Email" x-model="form.email" />
<select x-model="form.stage" class="form-input"><option>intake</option><option>discovery</option><option>proposal</option><option>active</option><option>churned</option></select>
<textarea class="form-input" x-model="form.notes" placeholder="Notes"></textarea>
<div class="btn-group"><button class="btn btn-primary" @click="save()">Save</button><button class="btn btn-secondary" @click="modal=false">Cancel</button></div>
</div>
</div>
<aside class="drawer" :class="{ open: drawer }"><button class="drawer-close" @click="drawer=false">×</button><p x-text="detail"></p></aside>
</div>
{% endblock %}
{% block scripts %}
<script>
function clientsCrud() {
return {
modal: false, drawer: false, detail: '',
form: { id: null, name: '', email: '', stage: 'intake', notes: '' },
openModal() { this.form = { id: null, name: '', email: '', stage: 'intake', notes: '' }; this.modal = true; },
show(id, name, email, stage, notes) { this.detail = name + ' · ' + email; this.form = { id, name, email, stage, notes }; this.drawer = true; },
async save() {
const body = { name: this.form.name, email: this.form.email, stage: this.form.stage, notes: this.form.notes };
if (this.form.id) await Cockpit.api('/clients/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
else await Cockpit.api('/clients', { method: 'POST', body: JSON.stringify(body) });
location.reload();
},
async setStage(id, stage, name, email, notes) {
await Cockpit.api('/clients/' + id, { method: 'PUT', body: JSON.stringify({ name, email, stage, notes }) });
Cockpit.toast('Stage updated', 'success'); location.reload();
}
};
}
</script>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all
router = APIRouter(prefix="/clients", tags=["clients"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def clients_page(request: Request):
rows = []
try:
rows = _iso_rows(fetch_all(
"""SELECT id, name, contact, email, stage, sector, mrr_estimate, notes, created_at, updated_at
FROM clients ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 200"""
))
except Exception:
rows = []
return templates.TemplateResponse("clients.html", {"request": request, "page_title": "Clients", "clients": rows})
+163
View File
@@ -0,0 +1,163 @@
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
import json
from app.db import fetch_all, fetch_one
from app.services.briefing import collect_briefing_data, serialize_stats
router = APIRouter(tags=["dashboard"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _safe_count(table: str, where: str = "") -> int:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COUNT(*) AS c FROM {table}{clause}")
return int(row["c"]) if row else 0
except Exception:
return 0
def _safe_sum(table: str, column: str, where: str = "") -> float:
try:
clause = f" WHERE {where}" if where else ""
row = fetch_one(f"SELECT COALESCE(SUM({column}), 0) AS total FROM {table}{clause}")
return float(row["total"]) if row else 0.0
except Exception:
return 0.0
def _briefing_payload(briefing: dict | None) -> dict:
"""Always use live DB stats; briefing text may be cached."""
payload: dict = {"content": None, "stats": serialize_stats(collect_briefing_data()), "created_at": None}
if not briefing:
return payload
payload["content"] = briefing.get("content")
payload["created_at"] = briefing.get("created_at")
return payload
@router.get("/")
async def dashboard(request: Request):
kpis = {
"deals_count": _safe_count("deals"),
"clients_count": _safe_count("clients"),
"pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
"pipeline_value": _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')"),
"browser_sessions_24h": 0,
"monitor_sites": _safe_count("monitored_sites", "is_active = TRUE"),
"supermarkets_count": _safe_count("supermarkets"),
"clients_active": _safe_count("clients", "stage = 'active'"),
"crm_partnerships": _safe_count("supermarkets", "partnership_status = 'active'"),
}
briefing = None
try:
briefing = fetch_one(
"SELECT id, content, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1"
)
if briefing and briefing.get("created_at"):
briefing["created_at"] = briefing["created_at"].isoformat()
except Exception:
briefing = None
agent_feed: list = []
try:
agent_feed = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events ORDER BY created_at DESC LIMIT 25
"""
)
for ev in agent_feed:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
agent_feed = []
approvals: list = []
try:
approvals = fetch_all(
"""
SELECT id, agent_name, event_type, title, body, status, created_at
FROM agent_events WHERE status = 'needs_approval'
ORDER BY created_at ASC LIMIT 25
"""
)
for ev in approvals:
if ev.get("created_at"):
ev["created_at"] = ev["created_at"].isoformat()
except Exception:
approvals = []
browser_sessions: list = []
try:
browser_sessions = fetch_all(
"""
SELECT id, url, final_url, title, task, status, created_at,
LEFT(content_text, 300) AS preview
FROM browser_sessions
ORDER BY created_at DESC LIMIT 8
"""
)
kpis["browser_sessions_24h"] = _safe_count(
"browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'"
)
for s in browser_sessions:
if s.get("created_at"):
s["created_at"] = s["created_at"].isoformat()
except Exception:
browser_sessions = []
monitor_sites: list = []
monitor_changes: list = []
try:
monitor_sites = fetch_all(
"""
SELECT id, name, url, last_title, last_crawled, is_active
FROM monitored_sites WHERE is_active = TRUE ORDER BY last_crawled DESC NULLS LAST LIMIT 10
"""
)
for s in monitor_sites:
if s.get("last_crawled"):
s["last_crawled"] = s["last_crawled"].isoformat()
monitor_changes = fetch_all(
"""
SELECT pc.id, pc.changed_at, ms.name, ms.url
FROM page_changes pc
JOIN monitored_sites ms ON ms.id = pc.site_id
WHERE pc.changed_at >= NOW() - INTERVAL '7 days'
ORDER BY pc.changed_at DESC LIMIT 10
"""
)
for c in monitor_changes:
if c.get("changed_at"):
c["changed_at"] = c["changed_at"].isoformat()
except Exception:
pass
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"page_title": "Herman · Command Center",
"kpis": kpis,
"briefing": briefing,
"briefing_payload": _briefing_payload(briefing),
"agent_feed": agent_feed,
"approvals": approvals,
"browser_sessions": browser_sessions,
"monitor_sites": monitor_sites,
"monitor_changes": monitor_changes,
},
)
@router.get("/marketing-redirect")
async def marketing_redirect():
return RedirectResponse(url="/marketing", status_code=302)
+33
View File
@@ -0,0 +1,33 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all
router = APIRouter(prefix="/deals", tags=["deals"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def deals_page(request: Request):
rows = []
try:
rows = _iso_rows(fetch_all(
"""SELECT d.id, d.title, d.value, d.stage, d.agent_owner, d.next_action, d.deadline,
d.created_at, c.name AS client_name
FROM deals d LEFT JOIN clients c ON c.id = d.client_id
ORDER BY d.updated_at DESC NULLS LAST LIMIT 200"""
))
except Exception:
rows = []
stages = {}
for r in rows:
st = r.get("stage") or "unknown"
stages.setdefault(st, []).append(r)
return templates.TemplateResponse("deals.html", {"request": request, "page_title": "Deals", "deals": rows, "kanban": stages})
+95
View File
@@ -0,0 +1,95 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all, fetch_one
router = APIRouter(prefix="/documents", tags=["documents"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def documents_page(request: Request):
summary = {
"documents": 0,
"total_words": 0,
"unique_words": 0,
"avg_sentiment": 0.0,
"positive": 0,
"neutral": 0,
"negative": 0,
}
try:
row = fetch_one(
"""
SELECT COUNT(*) AS docs,
COALESCE(SUM(word_count), 0) AS words,
COALESCE(AVG(sentiment_compound), 0) AS avg_sent
FROM document_analytics
"""
)
if row:
summary["documents"] = int(row["docs"] or 0)
summary["total_words"] = int(row["words"] or 0)
summary["avg_sentiment"] = round(float(row["avg_sent"] or 0), 3)
row = fetch_one("SELECT COUNT(DISTINCT lemma) AS c FROM document_word_counts WHERE NOT is_stopword")
summary["unique_words"] = int(row["c"] or 0) if row else 0
for label in ("positive", "neutral", "negative"):
row = fetch_one(
"SELECT COUNT(*) AS c FROM document_analytics WHERE sentiment_label = %s",
(label,),
)
summary[label] = int(row["c"] or 0) if row else 0
except Exception:
pass
top_words: list = []
documents: list = []
try:
top_words = fetch_all(
"""
SELECT lemma, MAX(token) AS token, SUM(count) AS total_count,
COUNT(DISTINCT storage_path) AS doc_count
FROM document_word_counts
WHERE NOT is_stopword
GROUP BY lemma
ORDER BY total_count DESC
LIMIT 30
"""
)
documents = _iso_rows(
fetch_all(
"""
SELECT filename, storage_path, doc_type, language, word_count,
unique_lemmas, sentiment_label, sentiment_compound,
sentiment_positive, sentiment_negative, sentiment_neutral,
extraction_method, analyzed_at
FROM document_analytics
ORDER BY analyzed_at DESC
LIMIT 50
"""
)
)
except Exception:
pass
return templates.TemplateResponse(
"documents.html",
{
"request": request,
"page_title": "Documents & Sentiment",
"summary": summary,
"top_words": top_words,
"documents": documents,
},
)
+79
View File
@@ -0,0 +1,79 @@
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all
from app.services.herman import AGENTS, chat, generate_briefing
router = APIRouter(prefix="/herman", tags=["herman"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def herman_page(request: Request):
history = []
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
return templates.TemplateResponse(
"herman_chat.html",
{"request": request, "page_title": "Herman", "agents": AGENTS, "history": history, "last_reply": None, "last_agent": None},
)
@router.post("/chat")
async def herman_chat(request: Request, message: str = Form(...)):
result = await chat(message)
history = []
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
return templates.TemplateResponse(
"herman_chat.html",
{
"request": request,
"page_title": "Herman",
"agents": AGENTS,
"history": history,
"last_reply": result.get("reply"),
"last_agent": result.get("agent_label"),
"user_message": message,
},
)
@router.post("/briefing")
async def herman_briefing_page(request: Request):
content = await generate_briefing()
history = []
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
return templates.TemplateResponse(
"herman_chat.html",
{
"request": request,
"page_title": "Herman",
"agents": AGENTS,
"history": history,
"last_reply": content,
"last_agent": "Herman · Briefing",
},
)
+52
View File
@@ -0,0 +1,52 @@
"""Hermes Telegram Command Center UI."""
from __future__ import annotations
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from app.config import settings
router = APIRouter(prefix="/hermes", tags=["hermes"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
CEO_CHAT_ID = 8859782446
CTO_CHAT_ID = 789036463
CEO_NAME = "Aïssa"
CTO_NAME = "Mo"
@router.get("", response_class=HTMLResponse)
async def hermes_dashboard(request: Request) -> HTMLResponse:
stats = {"conversations": 0, "messages": 0, "edges": 0, "embeddings": 0}
conversations = []
try:
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/stats")
if r.status_code == 200:
data = r.json()
stats = data.get("stats") or stats
conversations_resp = await client.get(
f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": 20}
)
if conversations_resp.status_code == 200:
conversations = conversations_resp.json().get("items") or []
except Exception:
pass
return templates.TemplateResponse(
"hermes.html",
{
"request": request,
"page_title": "Hermes",
"stats": stats,
"conversations": conversations,
"ceo_chat_id": CEO_CHAT_ID,
"cto_chat_id": CTO_CHAT_ID,
"allowed_sites": ["Airbnb", "Booking.com", "DuckDuckGo", "HolidayCheck"],
"blocked_sites": ["Google", "Vrbo", "Expedia", "Hotels.com", "TUI", "TripAdvisor"],
},
)
+72
View File
@@ -0,0 +1,72 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all, fetch_one
from app.services.marketing import evaluate_agent_rules
router = APIRouter(prefix="/marketing", tags=["marketing"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def marketing_page(request: Request):
evaluate_agent_rules()
posts, mentions, accounts, rules, logs = [], [], [], [], []
analytics = {"mention_count": 0, "avg_sentiment": 0}
try:
posts = _iso_rows(fetch_all(
"""SELECT sp.*, sa.platform, sa.username FROM scheduled_posts sp
JOIN social_accounts sa ON sp.account_id = sa.id
ORDER BY sp.scheduled_time DESC LIMIT 50"""
))
except Exception:
posts = []
try:
mentions = _iso_rows(fetch_all(
"SELECT * FROM social_mentions ORDER BY created_at DESC LIMIT 50"
))
row = fetch_one(
"SELECT COUNT(*) AS cnt, COALESCE(AVG(sentiment_score),0) AS avg FROM social_mentions"
)
if row:
analytics["mention_count"] = int(row["cnt"])
analytics["avg_sentiment"] = float(row["avg"])
except Exception:
mentions = []
try:
accounts = _iso_rows(fetch_all("SELECT * FROM social_accounts ORDER BY platform"))
except Exception:
accounts = []
try:
rules = _iso_rows(fetch_all("SELECT * FROM agent_rules ORDER BY id"))
logs = _iso_rows(fetch_all(
"""SELECT al.*, ar.name AS rule_name FROM agent_logs al
LEFT JOIN agent_rules ar ON al.rule_id = ar.id ORDER BY al.created_at DESC LIMIT 30"""
))
except Exception:
pass
return templates.TemplateResponse(
"marketing.html",
{
"request": request,
"page_title": "Marketing",
"scheduled_posts": posts,
"social_mentions": mentions,
"accounts": accounts,
"agent_rules": rules,
"agent_logs": logs,
"analytics": analytics,
},
)
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import json
from pathlib import Path
from uuid import uuid4
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, UploadFile
from pydantic import BaseModel, Field
from app.db import fetch_all, fetch_one
from app.services.social_publish import PLATFORMS, get_configured_channels, run_publish_job
router = APIRouter(prefix="/api/marketing", tags=["marketing-api"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
UPLOAD_DIR = BASE_DIR / "static" / "uploads" / "marketing"
def _serialize_row(row: dict | None) -> dict | None:
if not row:
return None
out = dict(row)
for key, value in list(out.items()):
if hasattr(value, "isoformat"):
out[key] = value.isoformat()
return out
def _serialize_rows(rows: list[dict]) -> list[dict]:
return [_serialize_row(row) for row in rows]
class PublishRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=5000)
image_url: str | None = None
media_ids: list[int] = Field(default_factory=list)
channels: list[str] = Field(default_factory=list)
@router.post("/upload")
async def upload_marketing_media(file: UploadFile = File(...)) -> dict:
data = await file.read()
if not data:
raise HTTPException(status_code=400, detail="empty file")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
ext = Path(file.filename or "upload.bin").suffix or ".bin"
filename = f"{uuid4().hex}{ext.lower()}"
path = UPLOAD_DIR / filename
path.write_bytes(data)
media_url = f"/static/uploads/marketing/{filename}"
row = fetch_one(
"""
INSERT INTO marketing_media (filename, original_name, file_path, media_url, mime_type, size_bytes, created_at)
VALUES (%s, %s, %s, %s, %s, %s, NOW())
RETURNING id
""",
(
filename,
file.filename or filename,
str(path),
media_url,
file.content_type or "application/octet-stream",
len(data),
),
)
return {"media_id": row["id"], "url": media_url}
@router.post("/publish", status_code=202)
def create_publish_job(body: PublishRequest, background_tasks: BackgroundTasks) -> dict:
channels = [c.strip().lower() for c in body.channels if c.strip()]
invalid = [c for c in channels if c not in PLATFORMS]
if invalid:
raise HTTPException(status_code=400, detail=f"Unsupported channels: {', '.join(invalid)}")
if not channels:
channels = list(PLATFORMS)
row = fetch_one(
"""
INSERT INTO social_publish_jobs (text, image_url, media_ids, channels, status, created_at, updated_at)
VALUES (%s, %s, %s::jsonb, %s::jsonb, %s, NOW(), NOW())
RETURNING id
""",
(
body.text,
body.image_url,
json.dumps(body.media_ids),
json.dumps(channels),
"queued",
),
)
job_id = int(row["id"])
background_tasks.add_task(run_publish_job, job_id, body.text, channels, body.image_url, body.media_ids)
return {"job_id": job_id, "status": "queued", "channels": channels}
@router.get("/publish/{job_id}")
def get_publish_job(job_id: int) -> dict:
row = fetch_one("SELECT * FROM social_publish_jobs WHERE id = %s", (job_id,))
if not row:
raise HTTPException(status_code=404, detail="Job not found")
return {"job": _serialize_row(row)}
@router.get("/publish/history")
def list_publish_history(limit: int = 50) -> dict:
safe_limit = max(1, min(limit, 200))
rows = fetch_all(
"SELECT * FROM social_publish_jobs ORDER BY created_at DESC LIMIT %s",
(safe_limit,),
)
return {"items": _serialize_rows(rows), "count": len(rows)}
@router.get("/channels")
def list_channels() -> dict:
items = get_configured_channels()
return {"items": items, "platforms": list(PLATFORMS)}
+9
View File
@@ -0,0 +1,9 @@
from fastapi import APIRouter
from fastapi.responses import RedirectResponse
router = APIRouter(prefix="/monitor", tags=["monitor"])
@router.get("")
async def monitor_page():
return RedirectResponse(url="/browser#monitor", status_code=302)
+20
View File
@@ -0,0 +1,20 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
router = APIRouter(tags=["ops"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("/ops")
async def ops_page(request: Request):
return templates.TemplateResponse(
"ops.html",
{
"request": request,
"page_title": "IT Ops",
},
)
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import os
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
TOOLS_API_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
router = APIRouter(prefix="/api/ops", tags=["ops-api"])
async def _proxy(method: str, path: str, request: Request) -> JSONResponse:
body = await request.body()
upstream = f"{TOOLS_API_URL}/ops{path}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.request(
method=method,
url=upstream,
params=dict(request.query_params),
content=body if body else None,
headers={"content-type": request.headers.get("content-type", "application/json")},
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"tools-api unavailable: {exc}") from exc
if resp.status_code >= 500:
raise HTTPException(status_code=502, detail=f"tools-api error {resp.status_code}")
try:
payload = resp.json()
except Exception: # noqa: BLE001
payload = {"raw": resp.text}
return JSONResponse(status_code=resp.status_code, content=payload)
@router.get("/status")
async def ops_status_proxy(request: Request):
return await _proxy("GET", "/status", request)
@router.get("/topology")
async def ops_topology_proxy(request: Request):
return await _proxy("GET", "/topology", request)
@router.post("/refresh")
async def ops_refresh_proxy(request: Request):
return await _proxy("POST", "/refresh", request)
@router.api_route("/{subpath:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
async def ops_generic_proxy(subpath: str, request: Request):
return await _proxy(request.method, f"/{subpath}", request)
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
from pathlib import Path
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from app.config import settings
router = APIRouter(tags=["packaging"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
class PackagingGenerateBody(BaseModel):
type: str = Field(default="folding_box")
width_mm: float = Field(default=120, gt=0)
height_mm: float = Field(default=80, gt=0)
depth_mm: float = Field(default=40, ge=0)
elements: dict[str, bool] = Field(default_factory=dict)
brand: dict[str, str] = Field(default_factory=dict)
barcode_value: str | None = Field(default=None)
@router.get("/packaging")
async def packaging_page(request: Request):
return templates.TemplateResponse(
"packaging.html",
{"request": request, "page_title": "Packaging Studio"},
)
@router.post("/api/packaging/generate")
async def proxy_packaging_generate(body: PackagingGenerateBody):
try:
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.post(
f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/generate",
json=body.model_dump(),
)
r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:400] if exc.response else str(exc)
raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.get("/api/packaging/projects")
async def proxy_packaging_projects(limit: int = 30):
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(
f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/projects",
params={"limit": limit},
)
r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:400] if exc.response else str(exc)
raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.get("/api/packaging/download/{project_id}")
async def proxy_packaging_download(project_id: str, format: str = "svg"):
try:
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.get(
f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/download/{project_id}",
params={"format": format},
)
r.raise_for_status()
media = r.headers.get("content-type", "application/octet-stream")
disposition = r.headers.get("content-disposition")
headers = {"content-disposition": disposition} if disposition else {}
return Response(content=r.content, media_type=media, headers=headers)
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:400] if exc.response else str(exc)
raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
+113
View File
@@ -0,0 +1,113 @@
"""Unified live platform feed — events with traceable sources."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from app.db import fetch_all
CHANNEL_ROUTES = {
"dashboard": "/",
"retail": "/retail",
"marketing": "/marketing",
"beurs": "/beurs",
"agents": "/agents",
"hermes": "/hermes",
"browser": "/browser",
"documents": "/documents",
"settings": "/settings",
}
def _resolve_source(row: dict[str, Any]) -> dict[str, Any]:
meta = row.get("metadata") or {}
if isinstance(meta, str):
import json
try:
meta = json.loads(meta)
except Exception:
meta = {}
source_url = meta.get("source_url") or meta.get("url") or meta.get("link")
source_label = meta.get("source") or meta.get("feed_name")
if not source_url:
channel = row.get("channel") or "dashboard"
source_url = CHANNEL_ROUTES.get(channel, "/")
source_label = source_label or f"Foodlinkk · {channel}"
if row.get("related_table") == "rss_items" and row.get("related_id"):
source_url = meta.get("link") or source_url
event_type = (row.get("event_type") or "").lower()
agent = (row.get("agent_name") or "").lower()
if event_type in ("briefing", "report"):
source_url = "/"
elif event_type in ("sync", "score", "import") and "retail" in agent:
source_url = "/retail"
elif event_type == "refresh" and "rss" in agent:
source_url = "/marketing"
elif event_type in ("sync",) and "halal" in agent:
source_url = "/retail"
elif agent == "herman":
source_url = "/"
elif agent in ("marketing", "rss_feeds"):
source_url = "/marketing"
elif agent in ("wholesale_scraper", "retail_intel"):
source_url = "/retail"
elif agent == "hermes":
source_url = "/hermes"
internal_url = source_url if source_url.startswith("/") else None
external_url = source_url if source_url and source_url.startswith("http") else None
return {
"source_url": source_url,
"source_label": source_label or "Foodlinkk platform",
"internal_url": internal_url,
"external_url": external_url,
}
def fetch_platform_events(limit: int = 80, agent: Optional[str] = None) -> list[dict[str, Any]]:
clauses, params = [], []
if agent:
clauses.append("LOWER(agent_name) = %s")
params.append(agent.lower())
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
rows = fetch_all(
f"""SELECT id, agent_name, agent_type, event_type, title, body, status,
channel, metadata, related_table, related_id, created_at
FROM agent_events{where}
ORDER BY created_at DESC LIMIT %s""",
tuple(params + [limit]),
)
events = []
for r in rows:
item = dict(r)
if item.get("created_at"):
item["created_at"] = item["created_at"].isoformat()
src = _resolve_source(item)
item.update(src)
item["click_url"] = src.get("external_url") or src.get("internal_url") or "/agents"
item["is_external"] = bool(src.get("external_url"))
events.append(item)
return events
def platform_stats() -> dict[str, Any]:
try:
total = fetch_all("SELECT COUNT(*) AS n FROM agent_events")[0]["n"]
pending = fetch_all("SELECT COUNT(*) AS n FROM agent_events WHERE status = 'needs_approval'")[0]["n"]
last_hour = fetch_all(
"SELECT COUNT(*) AS n FROM agent_events WHERE created_at >= NOW() - INTERVAL '1 hour'"
)[0]["n"]
except Exception:
total = pending = last_hour = 0
return {
"total_events": int(total or 0),
"pending_approvals": int(pending or 0),
"events_last_hour": int(last_hour or 0),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
+29
View File
@@ -0,0 +1,29 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all
router = APIRouter(prefix="/products", tags=["products"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def products_page(request: Request):
rows = []
try:
rows = _iso_rows(fetch_all(
"""SELECT p.id, p.name, p.status, p.margin_pct, p.moq, p.shelf_target, p.launch_date, p.created_at,
c.name AS client_name
FROM products p LEFT JOIN clients c ON c.id = p.client_id
ORDER BY p.created_at DESC LIMIT 200"""
))
except Exception:
rows = []
return templates.TemplateResponse("products.html", {"request": request, "page_title": "Products", "products": rows})
+39
View File
@@ -0,0 +1,39 @@
"""Proxy recommendations to Tools API."""
from __future__ import annotations
import os
import httpx
from fastapi import APIRouter
admin_router = None # patched into admin_api
TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
async def _proxy(method: str, path: str):
async with httpx.AsyncClient(timeout=60) as client:
r = await getattr(client, method.lower())(f"{TOOLS}{path}")
return r.json()
def register_recommendation_routes(router: APIRouter) -> None:
@router.get("/recommendations/pending")
async def reco_pending():
return await _proxy("GET", "/recommendations/pending")
@router.post("/recommendations/generate")
async def reco_generate():
return await _proxy("POST", "/recommendations/generate")
@router.post("/recommendations/{rec_id}/approve")
async def reco_approve(rec_id: int):
return await _proxy("POST", f"/recommendations/{rec_id}/approve")
@router.post("/recommendations/{rec_id}/dismiss")
async def reco_dismiss(rec_id: int):
return await _proxy("POST", f"/recommendations/{rec_id}/dismiss")
@router.post("/research/run")
async def research_run():
return await _proxy("POST", "/research/run")
+78
View File
@@ -0,0 +1,78 @@
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse, PlainTextResponse, Response
from fastapi.templating import Jinja2Templates
from app.db import fetch_all
from app.services.reports_export import EXPORT_DATASETS, export_all_json, fetch_dataset, list_datasets, to_csv
router = APIRouter(prefix="/reports", tags=["reports"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def reports_page(request: Request):
briefings, events = [], []
try:
briefings = _iso_rows(fetch_all(
"SELECT id, content, generated_by, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 20"
))
except Exception:
briefings = []
try:
events = _iso_rows(fetch_all(
"""SELECT id, agent_name, event_type, title, status, created_at FROM agent_events
WHERE event_type IN ('briefing','report','herman_chat') ORDER BY created_at DESC LIMIT 50"""
))
except Exception:
events = []
datasets = list_datasets()
return templates.TemplateResponse(
"reports.html",
{
"request": request,
"page_title": "Reports & Export",
"briefings": briefings,
"report_events": events,
"datasets": datasets,
},
)
@router.get("/api/datasets")
async def reports_datasets():
return JSONResponse({"items": list_datasets()})
@router.get("/api/export/{name}")
async def reports_export_one(name: str, format: str = Query("csv", pattern="^(csv|json)$"), limit: int = Query(10000, ge=1, le=50000)):
if name not in EXPORT_DATASETS:
raise HTTPException(404, "Dataset not found")
try:
rows = fetch_dataset(name, limit)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
if format == "json":
return JSONResponse({"dataset": name, "count": len(rows), "items": rows})
csv_text = to_csv(rows)
return PlainTextResponse(
csv_text,
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="foodlinkk_{name}.csv"'},
)
@router.get("/api/export-all")
async def reports_export_all(format: str = Query("json", pattern="^(json)$"), limit: int = Query(3000, ge=100, le=10000)):
bundle = export_all_json(limit)
return JSONResponse(bundle, headers={"Content-Disposition": 'attachment; filename="foodlinkk_full_export.json"'})
+333
View File
@@ -0,0 +1,333 @@
"""Retail intelligence map page + API proxy."""
from __future__ import annotations
import os
from typing import Any, Optional
import httpx
from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from pydantic import BaseModel
router = APIRouter()
BASE = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE / "templates"))
TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
class CrmLinkBody(BaseModel):
client_id: int
deal_id: Optional[int] = None
relationship_type: str = "prospect"
partnership_status: Optional[str] = None
notes: Optional[str] = None
class NoteBody(BaseModel):
body: str
title: Optional[str] = None
note_type: str = "general"
class MilestoneBody(BaseModel):
title: str
milestone_type: str = "custom"
client_id: Optional[int] = None
deal_id: Optional[int] = None
target_date: Optional[str] = None
value_eur: Optional[float] = None
notes: Optional[str] = None
class OwnershipBody(BaseModel):
new_owner: str
previous_owner: Optional[str] = None
change_type: str = "acquisition"
effective_date: Optional[str] = None
source: Optional[str] = None
notes: Optional[str] = None
class CalendarBody(BaseModel):
title: str
starts_at: str
description: Optional[str] = None
ends_at: Optional[str] = None
client_id: Optional[int] = None
deal_id: Optional[int] = None
location: Optional[str] = None
class MediaBody(BaseModel):
filename: str
storage_path: str
content_type: str = "image/jpeg"
caption: Optional[str] = None
async def _tools_get(path: str, params: Optional[dict] = None) -> Any:
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.get(f"{TOOLS}{path}", params=params or {})
resp.raise_for_status()
return resp.json()
async def _tools_post(path: str, params: Optional[dict] = None, json_body: Optional[dict] = None) -> Any:
async with httpx.AsyncClient(timeout=300) as client:
resp = await client.post(f"{TOOLS}{path}", params=params or {}, json=json_body)
resp.raise_for_status()
return resp.json()
async def _tools_delete(path: str) -> Any:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.delete(f"{TOOLS}{path}")
resp.raise_for_status()
return resp.json()
@router.get("/retail")
async def retail_page(request: Request):
stats = await _tools_get("/retail/stats")
filters = await _tools_get("/retail/filters")
schema = await _tools_get("/retail/schema")
trends = await _tools_get("/retail/trends", {"limit": 8})
crm = await _tools_get("/retail/crm/options")
return templates.TemplateResponse(
"retail.html",
{
"request": request,
"stats": stats,
"filters": filters,
"schema": schema,
"trends": trends.get("items", []),
"crm_options": crm,
},
)
@router.get("/api/retail/stats")
async def api_retail_stats(request: Request):
return JSONResponse(await _tools_get("/retail/stats", dict(request.query_params)))
@router.get("/api/retail/filters")
async def api_retail_filters():
return JSONResponse(await _tools_get("/retail/filters"))
@router.get("/api/retail/map")
async def api_retail_map(request: Request):
return JSONResponse(await _tools_get("/retail/map", dict(request.query_params)))
@router.get("/api/retail/list")
async def api_retail_list(request: Request):
return JSONResponse(await _tools_get("/retail/supermarkets", dict(request.query_params)))
@router.get("/api/retail/supermarkets/{store_id}")
async def api_retail_store(store_id: int):
return JSONResponse(await _tools_get(f"/retail/supermarkets/{store_id}"))
@router.get("/api/retail/opportunities")
async def api_retail_opportunities(request: Request):
return JSONResponse(await _tools_get("/retail/opportunities", dict(request.query_params)))
@router.get("/api/retail/trends")
async def api_retail_trends(request: Request):
return JSONResponse(await _tools_get("/retail/trends", dict(request.query_params)))
@router.get("/api/retail/crm/options")
async def api_crm_options():
return JSONResponse(await _tools_get("/retail/crm/options"))
@router.post("/api/retail/supermarkets/{store_id}/link")
async def api_link_store(store_id: int, body: CrmLinkBody):
return JSONResponse(await _tools_post(f"/retail/supermarkets/{store_id}/link", json_body=body.model_dump()))
@router.post("/api/retail/enrich")
async def api_retail_enrich(limit: int = Query(100, ge=1, le=200)):
return JSONResponse(await _tools_post("/retail/enrich", params={"limit": limit}))
@router.post("/api/retail/sync/{action}")
async def api_retail_sync(action: str, limit: int = Query(100, ge=1, le=300)):
paths = {
"halal": "/retail/sync/halal",
"contacts": f"/retail/sync/contacts?limit={limit}",
"trends": "/retail/sync/trends",
"opportunities": "/retail/compute-opportunities",
}
if action not in paths:
return JSONResponse({"error": "unknown action"}, status_code=400)
return JSONResponse(await _tools_post(paths[action]))
@router.get("/api/retail/export")
async def api_retail_export(request: Request):
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.get(f"{TOOLS}/retail/export", params=dict(request.query_params))
resp.raise_for_status()
return StreamingResponse(
iter([resp.text]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=retail_export.csv"},
)
# --- 360 workspace proxies ---
@router.get("/api/retail/360/{store_id}")
async def api_retail_360(store_id: int):
return JSONResponse(await _tools_get(f"/retail/360/{store_id}"))
@router.post("/api/retail/360/{store_id}/notes")
async def api_retail_note(store_id: int, body: NoteBody):
return JSONResponse(await _tools_post(f"/retail/360/{store_id}/notes", json_body=body.model_dump()))
@router.post("/api/retail/360/{store_id}/milestones")
async def api_retail_milestone(store_id: int, body: MilestoneBody):
return JSONResponse(await _tools_post(f"/retail/360/{store_id}/milestones", json_body=body.model_dump()))
@router.post("/api/retail/360/{store_id}/ownership")
async def api_retail_ownership(store_id: int, body: OwnershipBody):
return JSONResponse(await _tools_post(f"/retail/360/{store_id}/ownership", json_body=body.model_dump()))
@router.post("/api/retail/360/{store_id}/calendar")
async def api_retail_calendar(store_id: int, body: CalendarBody):
return JSONResponse(await _tools_post(f"/retail/360/{store_id}/calendar", json_body=body.model_dump()))
@router.post("/api/retail/360/{store_id}/media")
async def api_retail_media(store_id: int, body: MediaBody):
return JSONResponse(await _tools_post(f"/retail/360/{store_id}/media", json_body=body.model_dump()))
@router.get("/api/retail/cities")
async def api_retail_cities(request: Request):
return JSONResponse(await _tools_get("/retail/cities", dict(request.query_params)))
@router.post("/api/retail/cities/sync")
async def api_retail_cities_sync(limit: int = Query(50, ge=1, le=200)):
return JSONResponse(await _tools_post("/retail/cities/sync", params={"limit": limit}))
@router.get("/api/retail/wholesalers")
async def api_retail_wholesalers(request: Request):
return JSONResponse(await _tools_get("/retail/wholesalers", dict(request.query_params)))
@router.post("/api/retail/wholesalers/import")
async def api_retail_wholesalers_import():
return JSONResponse(await _tools_post("/retail/wholesalers/import"))
@router.get("/api/retail/rss/live")
async def api_retail_rss(request: Request):
return JSONResponse(await _tools_get("/retail/rss/live", dict(request.query_params)))
@router.post("/api/retail/rss/refresh")
async def api_retail_rss_refresh():
return JSONResponse(await _tools_post("/retail/rss/refresh"))
@router.get("/api/retail/rss/bookmarks")
async def api_rss_bookmarks(request: Request):
return JSONResponse(await _tools_get("/retail/rss/bookmarks", dict(request.query_params)))
@router.post("/api/retail/rss/bookmarks")
async def api_rss_bookmark_add(request: Request):
body = await request.json()
return JSONResponse(await _tools_post("/retail/rss/bookmarks", json_body=body))
@router.delete("/api/retail/rss/bookmarks/{rss_item_id}")
async def api_rss_bookmark_delete(rss_item_id: int):
return JSONResponse(await _tools_delete(f"/retail/rss/bookmarks/{rss_item_id}"))
@router.get("/api/retail/wholesalers/meta")
async def api_wholesalers_meta():
return JSONResponse(await _tools_get("/retail/wholesalers/meta"))
@router.get("/api/retail/wholesalers/{wh_id}/contacts")
async def api_wholesaler_contacts(wh_id: int):
return JSONResponse(await _tools_get(f"/retail/wholesalers/{wh_id}/contacts"))
@router.post("/api/retail/wholesalers/{wh_id}/contacts")
async def api_wholesaler_contact_add(wh_id: int, request: Request):
body = await request.json()
return JSONResponse(await _tools_post(f"/retail/wholesalers/{wh_id}/contacts", json_body=body))
@router.get("/api/retail/promo-campaigns")
async def api_promo_campaigns(request: Request):
return JSONResponse(await _tools_get("/retail/promo-campaigns", dict(request.query_params)))
@router.post("/api/retail/promo-campaigns")
async def api_promo_campaign_add(request: Request):
body = await request.json()
return JSONResponse(await _tools_post("/retail/promo-campaigns", json_body=body))
@router.post("/api/retail/reclamefolder/refresh")
async def api_reclamefolder_refresh():
return JSONResponse(await _tools_post("/retail/reclamefolder/refresh", json_body={}))
@router.get("/api/retail/reclamefolder/live")
async def api_reclamefolder_live(request: Request):
return JSONResponse(await _tools_get("/retail/reclamefolder/live", dict(request.query_params)))
@router.get("/api/retail/reclamefolder/chains")
async def api_reclamefolder_chains():
return JSONResponse(await _tools_get("/retail/reclamefolder/chains"))
@router.get("/api/retail/market/supermarkets")
async def api_supermarket_market():
return JSONResponse(await _tools_get("/retail/market/supermarkets"))
@router.get("/api/retail/market/food-trends")
async def api_food_trends():
return JSONResponse(await _tools_get("/retail/market/food-trends"))
@router.get("/api/retail/market/concepts")
async def api_market_concepts(request: Request):
return JSONResponse(await _tools_get("/retail/market/concepts", dict(request.query_params)))
@router.get("/api/retail/live-dashboard")
async def api_retail_live_dashboard():
return JSONResponse(await _tools_get("/retail/live-dashboard"))
@router.get("/api/retail/market/stocks")
async def api_retail_market_stocks():
return JSONResponse(await _tools_get("/retail/market/stocks"))
@router.get("/api/retail/regulations")
async def api_retail_regulations(request: Request):
return JSONResponse(await _tools_get("/retail/regulations", dict(request.query_params)))
+21
View File
@@ -0,0 +1,21 @@
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from pathlib import Path
router = APIRouter(tags=["settings"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("/settings")
async def settings_page(request: Request, tab: str = "email"):
allowed = ("email", "general", "permissions", "social")
return templates.TemplateResponse(
"settings.html",
{
"request": request,
"page_title": "Settings",
"active_tab": tab if tab in allowed else "email",
},
)
+383
View File
@@ -0,0 +1,383 @@
"""Settings API — email accounts stored in PostgreSQL."""
from __future__ import annotations
import smtplib
import json
from datetime import datetime
from email.mime.text import MIMEText
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.db import execute, fetch_all, fetch_one
from app.services import agent_souls
from app.services.social_publish import PLATFORMS, get_integration, test_connection
settings_router = APIRouter(prefix="/api/settings", tags=["settings"])
class EmailAccountBody(BaseModel):
label: str = Field(..., max_length=128)
email_address: str = Field(..., max_length=255)
provider: str = Field(default="custom", max_length=32)
is_active: bool = False
smtp_host: Optional[str] = None
smtp_port: int = 587
smtp_user: Optional[str] = None
smtp_password: Optional[str] = None
imap_host: Optional[str] = None
imap_port: int = 993
imap_user: Optional[str] = None
imap_password: Optional[str] = None
sync_enabled: bool = False
def _mask_account(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
out["smtp_password_set"] = bool(row.get("smtp_password"))
out["imap_password_set"] = bool(row.get("imap_password"))
out.pop("smtp_password", None)
out.pop("imap_password", None)
return out
def _deactivate_all() -> None:
execute("UPDATE email_accounts SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE")
def _test_smtp_config(
smtp_host: str,
smtp_port: int,
smtp_user: str,
smtp_pass: str,
from_addr: str,
) -> tuple[bool, str]:
if not smtp_host or not from_addr:
return False, "SMTP host en from-adres zijn verplicht"
try:
msg = MIMEText("Foodlinkk SMTP test — Herman email settings OK.", "plain", "utf-8")
msg["Subject"] = "Foodlinkk test email"
msg["From"] = from_addr
msg["To"] = from_addr
with smtplib.SMTP(smtp_host, smtp_port, timeout=25) as server:
server.ehlo()
if smtp_port == 587:
server.starttls()
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.sendmail(from_addr, [from_addr], msg.as_string())
return True, f"Testmail verstuurd naar {from_addr}"
except Exception as exc:
return False, str(exc)
def _resolve_password(new: Optional[str], existing: Optional[str]) -> Optional[str]:
if new is not None and new != "":
return new
return existing
SOCIAL_PLATFORM_FIELDS: dict[str, tuple[str, ...]] = {
"twitter": ("api_key", "api_secret", "access_token", "access_secret"),
"linkedin": ("access_token", "person_urn"),
"instagram": ("access_token", "page_id"),
"facebook": ("access_token", "page_id"),
"tiktok": ("access_token", "open_id"),
"pinterest": ("access_token", "board_id"),
}
SOCIAL_SECRET_FIELDS = {"api_secret", "access_secret", "access_token", "api_key"}
class SocialIntegrationBody(BaseModel):
api_key: Optional[str] = None
api_secret: Optional[str] = None
access_token: Optional[str] = None
access_secret: Optional[str] = None
person_urn: Optional[str] = None
page_id: Optional[str] = None
open_id: Optional[str] = None
board_id: Optional[str] = None
is_active: Optional[bool] = None
def _mask_social_row(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
config = out.get("config") or {}
if isinstance(config, str):
try:
config = json.loads(config)
except Exception:
config = {}
if not isinstance(config, dict):
config = {}
masked = dict(config)
for key in SOCIAL_SECRET_FIELDS:
if key in config:
masked[f"{key}_set"] = bool(config.get(key))
masked.pop(key, None)
out["config"] = masked
return out
def _normalize_social_platform(platform: str) -> str:
value = (platform or "").strip().lower()
if value not in PLATFORMS:
raise HTTPException(status_code=400, detail=f"Unsupported platform: {platform}")
return value
@settings_router.get("/email")
def list_email_accounts() -> dict[str, Any]:
try:
rows = fetch_all("SELECT * FROM email_accounts ORDER BY is_active DESC, updated_at DESC")
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"accounts": [_mask_account(r) for r in rows]}
@settings_router.post("/email")
def create_email_account(body: EmailAccountBody) -> dict[str, Any]:
if body.is_active:
_deactivate_all()
try:
row = fetch_one(
"""
INSERT INTO email_accounts (
label, email_address, provider, is_active,
smtp_host, smtp_port, smtp_user, smtp_password,
imap_host, imap_port, imap_user, imap_password, sync_enabled
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
body.label,
body.email_address,
body.provider,
body.is_active,
body.smtp_host,
body.smtp_port,
body.smtp_user or body.email_address,
body.smtp_password or "",
body.imap_host,
body.imap_port,
body.imap_user or body.email_address,
body.imap_password or "",
body.sync_enabled,
),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "account": _mask_account(row)}
@settings_router.put("/email/{account_id}")
def update_email_account(account_id: int, body: EmailAccountBody) -> dict[str, Any]:
existing = fetch_one("SELECT * FROM email_accounts WHERE id = %s", (account_id,))
if not existing:
raise HTTPException(status_code=404, detail="Account not found")
if body.is_active:
_deactivate_all()
smtp_pass = _resolve_password(body.smtp_password, existing.get("smtp_password"))
imap_pass = _resolve_password(body.imap_password, existing.get("imap_password"))
try:
row = fetch_one(
"""
UPDATE email_accounts SET
label=%s, email_address=%s, provider=%s, is_active=%s,
smtp_host=%s, smtp_port=%s, smtp_user=%s, smtp_password=%s,
imap_host=%s, imap_port=%s, imap_user=%s, imap_password=%s,
sync_enabled=%s, updated_at=NOW()
WHERE id=%s RETURNING *
""",
(
body.label,
body.email_address,
body.provider,
body.is_active,
body.smtp_host,
body.smtp_port,
body.smtp_user or body.email_address,
smtp_pass,
body.imap_host,
body.imap_port,
body.imap_user or body.email_address,
imap_pass,
body.sync_enabled,
account_id,
),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "account": _mask_account(row)}
@settings_router.delete("/email/{account_id}")
def delete_email_account(account_id: int) -> dict[str, Any]:
execute("DELETE FROM email_accounts WHERE id = %s", (account_id,))
return {"ok": True}
@settings_router.post("/email/{account_id}/activate")
def activate_email_account(account_id: int) -> dict[str, Any]:
existing = fetch_one("SELECT id FROM email_accounts WHERE id = %s", (account_id,))
if not existing:
raise HTTPException(status_code=404, detail="Account not found")
_deactivate_all()
row = fetch_one(
"UPDATE email_accounts SET is_active=TRUE, updated_at=NOW() WHERE id=%s RETURNING *",
(account_id,),
)
return {"ok": True, "account": _mask_account(row)}
@settings_router.post("/email/{account_id}/test")
def test_saved_email_account(account_id: int) -> dict[str, Any]:
row = fetch_one("SELECT * FROM email_accounts WHERE id = %s", (account_id,))
if not row:
raise HTTPException(status_code=404, detail="Account not found")
ok, message = _test_smtp_config(
row.get("smtp_host") or "",
int(row.get("smtp_port") or 587),
row.get("smtp_user") or row.get("email_address") or "",
row.get("smtp_password") or "",
row.get("email_address") or row.get("smtp_user") or "",
)
status = "ok" if ok else "failed"
execute(
"""
UPDATE email_accounts SET last_test_at=NOW(), last_test_status=%s, last_test_message=%s, updated_at=NOW()
WHERE id=%s
""",
(status, message[:500], account_id),
)
return {"ok": ok, "message": message}
@settings_router.post("/email/test")
def test_email_config(body: EmailAccountBody) -> dict[str, Any]:
"""Test SMTP without saving (form preview)."""
if not body.smtp_password:
raise HTTPException(status_code=400, detail="SMTP wachtwoord is verplicht voor test zonder opgeslagen account")
ok, message = _test_smtp_config(
body.smtp_host or "",
body.smtp_port,
body.smtp_user or body.email_address,
body.smtp_password,
body.email_address,
)
return {"ok": ok, "message": message}
@settings_router.get("/social")
def list_social_integrations() -> dict[str, Any]:
rows = fetch_all("SELECT * FROM social_integrations ORDER BY platform")
return {"items": [_mask_social_row(r) for r in rows], "platforms": list(PLATFORMS)}
@settings_router.put("/social/{platform}")
def save_social_integration(platform: str, body: SocialIntegrationBody) -> dict[str, Any]:
platform = _normalize_social_platform(platform)
allowed_fields = set(SOCIAL_PLATFORM_FIELDS[platform])
incoming = body.model_dump(exclude_none=True)
existing = fetch_one("SELECT * FROM social_integrations WHERE platform = %s", (platform,))
existing_config = {}
if existing:
existing_config = existing.get("config") or {}
if isinstance(existing_config, str):
try:
existing_config = json.loads(existing_config)
except Exception:
existing_config = {}
if not isinstance(existing_config, dict):
existing_config = {}
config = dict(existing_config)
for field in allowed_fields:
if field not in incoming:
continue
value = incoming.get(field)
if field in SOCIAL_SECRET_FIELDS:
if value is not None and value != "":
config[field] = value
else:
config[field] = value
is_active = body.is_active
if is_active is None:
is_active = bool(existing.get("is_active")) if existing else True
row = fetch_one(
"""
INSERT INTO social_integrations (platform, config, is_active, updated_at)
VALUES (%s, %s::jsonb, %s, NOW())
ON CONFLICT (platform) DO UPDATE SET
config = EXCLUDED.config,
is_active = EXCLUDED.is_active,
updated_at = NOW()
RETURNING *
""",
(platform, json.dumps(config), is_active),
)
return {"ok": True, "integration": _mask_social_row(row)}
@settings_router.post("/social/{platform}/test")
def test_social_integration(platform: str) -> dict[str, Any]:
platform = _normalize_social_platform(platform)
integration = get_integration(platform)
if not integration:
raise HTTPException(status_code=404, detail="Integration not configured")
result = test_connection(platform, integration)
status = "ok" if result.get("ok") else "failed"
message = (result.get("message") or result.get("error") or "")[:500]
try:
execute(
"""
UPDATE social_integrations
SET last_test_at=NOW(), last_test_status=%s, last_test_message=%s, updated_at=NOW()
WHERE platform=%s
""",
(status, message, platform),
)
except Exception:
pass
return {"platform": platform, **result}
class PermissionBody(BaseModel):
granted: bool
@settings_router.get("/permissions")
def list_permissions() -> dict[str, Any]:
items = agent_souls.list_permissions()
granted = sum(1 for i in items if i.get("granted"))
return {"items": items, "granted_count": granted, "total": len(items)}
@settings_router.put("/permissions/{module_key}")
def update_permission(module_key: str, body: PermissionBody) -> dict[str, Any]:
row = agent_souls.update_permission(module_key, body.granted)
if not row:
raise HTTPException(404, "Module not found")
return {"permission": row}
@settings_router.post("/permissions/grant-all")
def grant_all_permissions() -> dict[str, Any]:
n = agent_souls.grant_all_permissions()
return {"ok": True, "granted_count": n, "message": f"Herman heeft nu {n} module-rechten"}
+16
View File
@@ -0,0 +1,16 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
router = APIRouter(prefix="/studio", tags=["studio"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("")
async def studio_page(request: Request):
return templates.TemplateResponse(
"studio.html",
{"request": request, "page_title": "AI Studio"},
)
+27
View File
@@ -0,0 +1,27 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def suppliers_page(request: Request):
rows = []
try:
rows = _iso_rows(fetch_all(
"""SELECT id, name, country, category, moq, lead_time_days, rating, contact, created_at
FROM suppliers ORDER BY rating DESC NULLS LAST, name ASC LIMIT 200"""
))
except Exception:
rows = []
return templates.TemplateResponse("suppliers.html", {"request": request, "page_title": "Suppliers", "suppliers": rows})
+27
View File
@@ -0,0 +1,27 @@
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from app.db import fetch_all
router = APIRouter(prefix="/voice", tags=["voice"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
return rows
@router.get("")
async def voice_page(request: Request):
events = []
try:
events = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, status, created_at FROM agent_events
WHERE channel = 'voice' OR event_type LIKE 'voice%%' ORDER BY created_at DESC LIMIT 25"""
))
except Exception:
events = []
return templates.TemplateResponse("voice.html", {"request": request, "page_title": "Voice", "voice_events": events})