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:
@@ -0,0 +1 @@
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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 %}
|
||||
@@ -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))
|
||||
@@ -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}
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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 %}
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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"],
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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)}
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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})
|
||||
@@ -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")
|
||||
@@ -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"'})
|
||||
@@ -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)))
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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"}
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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})
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user