SysOps: deploy-all — 2026-06-09 10:41 UTC

This commit is contained in:
sysops
2026-06-09 10:41:13 +00:00
parent 69fe67cc0e
commit 21ea3a2c81
82 changed files with 8906 additions and 981 deletions
+193
View File
@@ -0,0 +1,193 @@
"""Agent action approval queue — gate before executing sensitive queries."""
from __future__ import annotations
import json
from typing import Any, Optional
from app.db import execute, fetch_all, fetch_one
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
out = dict(row)
for key, val in list(out.items()):
if hasattr(val, "isoformat"):
out[key] = val.isoformat()
return out
def has_pending_request(agent_key: str, action_type: str | None = None) -> bool:
clauses = ["agent_key = %s", "status = 'pending'", "created_at >= CURRENT_DATE"]
params: list[Any] = [agent_key.strip().lower()]
if action_type:
clauses.append("action_type = %s")
params.append(action_type)
row = fetch_one(
f"SELECT id FROM agent_action_requests WHERE {' AND '.join(clauses)} LIMIT 1",
tuple(params),
)
return bool(row)
def create_request(
agent_key: str,
title: str,
action_type: str = "query",
query_payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
key = (agent_key or "").strip().lower()
if not key or not title.strip():
raise ValueError("agent_key and title are required")
row = fetch_one(
"""
INSERT INTO agent_action_requests (agent_key, action_type, title, query_payload, status)
VALUES (%s, %s, %s, %s::jsonb, 'pending')
RETURNING *
""",
(key, action_type, title.strip(), json.dumps(query_payload or {})),
)
req = _serialize(row) or {}
try:
execute(
"""
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
key,
"agent_request",
"approval_request",
title.strip(),
f"Wacht op goedkeuring — {action_type}",
"needs_approval",
"agents",
json.dumps({"request_id": req.get("id"), "action_type": action_type}),
),
)
except Exception:
pass
return req
def list_requests(status: Optional[str] = None, limit: int = 50) -> list[dict[str, Any]]:
clauses, params = [], []
if status:
clauses.append("status = %s")
params.append(status)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
safe_limit = max(1, min(limit, 200))
rows = fetch_all(
f"SELECT * FROM agent_action_requests{where} ORDER BY created_at DESC LIMIT %s",
tuple(params + [safe_limit]),
)
return [_serialize(r) for r in rows]
def get_request(request_id: int) -> dict[str, Any] | None:
return _serialize(fetch_one("SELECT * FROM agent_action_requests WHERE id = %s", (request_id,)))
def approve_request(request_id: int, approved_by: str = "ceo") -> dict[str, Any]:
row = fetch_one(
"""
UPDATE agent_action_requests
SET status = 'approved', approved_by = %s, reviewed_at = NOW()
WHERE id = %s AND status = 'pending'
RETURNING *
""",
(approved_by, request_id),
)
if not row:
raise ValueError("Request not found or not pending")
req = _serialize(row) or {}
try:
execute(
"""
UPDATE agent_events SET status = 'approved'
WHERE status = 'needs_approval'
AND metadata->>'request_id' = %s
""",
(str(request_id),),
)
except Exception:
pass
return req
def reject_request(request_id: int, reason: str = "", rejected_by: str = "ceo") -> dict[str, Any]:
row = fetch_one(
"""
UPDATE agent_action_requests
SET status = 'rejected', approved_by = %s, rejection_reason = %s, reviewed_at = NOW()
WHERE id = %s AND status = 'pending'
RETURNING *
""",
(rejected_by, (reason or "")[:500], request_id),
)
if not row:
raise ValueError("Request not found or not pending")
req = _serialize(row) or {}
try:
execute(
"""
UPDATE agent_events SET status = 'rejected'
WHERE status = 'needs_approval'
AND metadata->>'request_id' = %s
""",
(str(request_id),),
)
except Exception:
pass
return req
def mark_executed(request_id: int, result: dict[str, Any] | None = None) -> dict[str, Any]:
row = fetch_one(
"""
UPDATE agent_action_requests
SET status = 'executed', executed_at = NOW(), result = %s::jsonb
WHERE id = %s AND status = 'approved'
RETURNING *
""",
(json.dumps(result or {}), request_id),
)
if not row:
raise ValueError("Request not approved or not found")
req = _serialize(row) or {}
try:
from app.services import projects as project_svc
payload = req.get("query_payload") or {}
if isinstance(payload, str):
import json as _json
try:
payload = _json.loads(payload)
except Exception:
payload = {}
pid = payload.get("project_id")
project_svc.register_agent_output(
asset_type=str(req.get("action_type") or "agent_action"),
title=req.get("title") or f"Agent actie #{request_id}",
ref_id=str(request_id),
payload={"result": result or {}, "action_type": req.get("action_type")},
project_id=int(pid) if pid else None,
source_agent=str(req.get("agent_key") or "agent"),
created_by=str(req.get("approved_by") or "ceo"),
)
except Exception:
pass
return req
def require_approved(request_id: int) -> dict[str, Any]:
req = get_request(request_id)
if not req:
raise PermissionError("Approval request not found")
if req.get("status") != "approved":
raise PermissionError(f"Request status is {req.get('status')}, approval required")
return req
+27 -7
View File
@@ -10,7 +10,13 @@ def list_souls() -> list[dict[str, Any]]:
rows = fetch_all(
"""SELECT s.*,
(SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count,
(SELECT MAX(created_at) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS last_event_at
(SELECT MAX(created_at) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS last_event_at,
(SELECT title FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
ORDER BY created_at DESC LIMIT 1) AS current_task,
(SELECT status FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
ORDER BY created_at DESC LIMIT 1) AS current_status,
(SELECT event_type FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
ORDER BY created_at DESC LIMIT 1) AS current_event_type
FROM agent_souls s ORDER BY s.display_name"""
)
return [dict(r) for r in rows]
@@ -25,13 +31,27 @@ def get_soul(agent_key: str) -> Optional[dict[str, Any]]:
)
if not row:
return None
events = fetch_all(
"""SELECT id, event_type, title, status, created_at FROM agent_events
WHERE LOWER(agent_name) = %s ORDER BY created_at DESC LIMIT 15""",
(agent_key.lower(),),
)
out = dict(row)
out["recent_events"] = [dict(e) for e in events]
out["recent_events"] = list_agent_events(agent_key, limit=15)
return out
def list_agent_events(agent_key: str, limit: int = 50) -> list[dict[str, Any]]:
rows = fetch_all(
"""SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at
FROM agent_events
WHERE LOWER(agent_name) = %s
ORDER BY created_at DESC
LIMIT %s""",
(agent_key.lower(), limit),
)
out: list[dict[str, Any]] = []
for row in rows:
ev = dict(row)
for key in ("created_at", "completed_at"):
if ev.get(key) is not None and hasattr(ev[key], "isoformat"):
ev[key] = ev[key].isoformat()
out.append(ev)
return out
+187 -11
View File
@@ -54,6 +54,74 @@ def collect_briefing_data() -> dict[str, Any]:
data["pipeline_eur"] = _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')")
data["pending_approvals"] = _safe_count("agent_events", "status = 'needs_approval'")
try:
data["pending_approval_requests"] = fetch_all(
"""SELECT id, agent_key, action_type, title, query_payload, created_at
FROM agent_action_requests WHERE status = 'pending'
ORDER BY created_at ASC LIMIT 15"""
)
data["pending_approvals"] = len(data["pending_approval_requests"])
except Exception:
data["pending_approval_requests"] = []
try:
data["recent_executed_actions"] = fetch_all(
"""SELECT id, agent_key, action_type, title, result, executed_at, approved_by
FROM agent_action_requests
WHERE status = 'executed' AND executed_at >= NOW() - INTERVAL '24 hours'
ORDER BY executed_at DESC LIMIT 12"""
)
except Exception:
data["recent_executed_actions"] = []
try:
data["project_assets_recent"] = fetch_all(
"""SELECT pa.title, pa.asset_type, pa.source_agent, pa.created_at, cp.name AS project_name
FROM project_assets pa
JOIN cockpit_projects cp ON cp.id = pa.project_id
WHERE pa.created_at >= NOW() - INTERVAL '24 hours'
ORDER BY pa.created_at DESC LIMIT 15"""
)
except Exception:
data["project_assets_recent"] = []
try:
data["ops_maintenance_open"] = fetch_all(
"""SELECT severity, title, body, created_at FROM ops_maintenance_notes
WHERE resolved = false ORDER BY created_at DESC LIMIT 8"""
)
except Exception:
data["ops_maintenance_open"] = []
try:
data["config_backups_recent"] = fetch_all(
"""SELECT status, message, commit_ref, created_at FROM config_backups
ORDER BY created_at DESC LIMIT 5"""
)
except Exception:
data["config_backups_recent"] = []
try:
data["sysops_activity_24h"] = fetch_all(
"""SELECT action_type, title, body, commit_ref, files_changed, status, created_at
FROM sysops_activity
WHERE created_at >= NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC LIMIT 20"""
)
except Exception:
data["sysops_activity_24h"] = []
try:
data["sysops_events_24h"] = fetch_all(
"""SELECT event_type, title, body, status, created_at, metadata
FROM agent_events
WHERE LOWER(agent_name) = 'sysops'
AND created_at >= NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC LIMIT 15"""
)
except Exception:
data["sysops_events_24h"] = []
try:
data["deals_by_stage"] = fetch_all(
"SELECT stage, COUNT(*) AS cnt, COALESCE(SUM(value), 0) AS total FROM deals GROUP BY stage ORDER BY cnt DESC"
@@ -204,20 +272,85 @@ def collect_briefing_data() -> dict[str, Any]:
except Exception:
data["regulation_highlights"] = []
try:
data["trending_food"] = fetch_all(
"""SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url,
f.category, i.published_at
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
WHERE f.category IN ('food', 'markt', 'supermarkt', 'retail', 'kant-en-klaar')
OR f.name ILIKE '%retaildetail%'
ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 10"""
)
except Exception:
data["trending_food"] = []
try:
data["food_market_highlights"] = fetch_all(
"""SELECT i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url, f.category
FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
WHERE f.category IN ('markt', 'supermarkt', 'kant-en-klaar', 'retail')
OR i.title ILIKE ANY (ARRAY['%supermarkt%','%retail%','%jumbo%','%ahold%','%halal%','%maaltijd%'])
WHERE f.category IN ('food', 'markt', 'supermarkt', 'kant-en-klaar', 'retail')
OR f.name ILIKE '%retaildetail%'
ORDER BY i.published_at DESC NULLS LAST LIMIT 10"""
)
except Exception:
data["food_market_highlights"] = []
data["activity_log"] = _build_activity_log(data)
return data
def _build_activity_log(data: dict[str, Any]) -> list[str]:
lines: list[str] = []
for row in data.get("pending_approval_requests") or []:
agent = row.get("agent_key") or "agent"
action = row.get("action_type") or "actie"
title = row.get("title") or ""
if action == "maintenance_scan":
lines.append(f"⏳ SysOps vraagt toestemming voor update-scan: {title}")
elif action == "config_backup":
lines.append(f"⏳ SysOps vraagt goedkeuring backup: {title}")
else:
lines.append(f"{agent} wacht op goedkeuring ({action}): {title}")
for row in data.get("recent_executed_actions") or []:
lines.append(f"✓ Uitgevoerd door {row.get('agent_key')}: {row.get('title')}")
for row in data.get("project_assets_recent") or []:
ts = row.get("created_at")
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts or "")[:16]
lines.append(
f"📁 Project asset ({row.get('project_name')}): {row.get('title')} "
f"[{row.get('asset_type')} · {row.get('source_agent')}] {ts_s}"
)
for row in data.get("ops_maintenance_open") or []:
lines.append(f"🔧 IT Ops [{row.get('severity')}]: {row.get('title')}")
for row in data.get("config_backups_recent") or []:
lines.append(f"💾 Backup {row.get('status')}: {row.get('message') or row.get('commit_ref')}")
for row in data.get("sysops_activity_24h") or []:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
cref = f" [{row.get('commit_ref')}]" if row.get("commit_ref") else ""
lines.append(f"🖥️ SysOps {row.get('action_type')}: {row.get('title')}{cref} ({ts_s})")
for row in data.get("sysops_events_24h") or []:
if (row.get("event_type") or "") == "gitea_sync":
continue
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(f"🔧 SysOps: {row.get('title')} ({ts_s})")
for row in (data.get("recent_events") or [])[:8]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(f"{row.get('agent_name')}: {row.get('title')} ({ts_s})")
return lines[:25]
def build_template_report(data: dict[str, Any]) -> str:
lines = [
f"# Foodlinkk Dagrapport — {data['date']}",
@@ -272,8 +405,30 @@ def build_template_report(data: dict[str, Any]) -> str:
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16]
lines.append(f"- [{ts_s}] {row.get('title')} ({row.get('client_name') or '-'})")
if data.get("sysops_activity_24h"):
lines.extend(["", "## SysOps IT — laatste 24 uur"])
for row in data["sysops_activity_24h"][:12]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
cref = f" · commit `{row.get('commit_ref')}`" if row.get("commit_ref") else ""
lines.append(f"- [{ts_s}] **{row.get('title')}**{cref}")
if row.get("body"):
lines.append(f" {str(row.get('body'))[:200]}")
if data.get("pending_approval_requests"):
lines.extend(["", "## ⏳ Wacht op jouw goedkeuring (agents)"])
for row in data["pending_approval_requests"]:
action = row.get("action_type") or ""
label = "Update-scan VM106" if action == "maintenance_scan" else action
lines.append(f"- **{row.get('agent_key')}** · {label}: {row.get('title')}")
if data.get("activity_log"):
lines.extend(["", "## Herman activiteitenlog (24u)"])
for entry in data["activity_log"][:20]:
lines.append(f"- {entry}")
if data.get("pending_items"):
lines.extend(["", "## ⚠️ Wacht op jouw goedkeuring"])
lines.extend(["", "## Legacy goedkeuringen"])
for row in data["pending_items"]:
lines.append(f"- {row.get('agent_name')}: {row.get('title')}")
@@ -289,21 +444,26 @@ async def _ai_executive_summary(data: dict[str, Any]) -> str:
for row in data.get("milestones_pending") or []:
ms_lines += f"- {row.get('title')} ({row.get('chain') or 'CRM'}) deadline {row.get('target_date') or '?'}\n"
activity = "\n".join((data.get("activity_log") or [])[:15]) or "- Geen recente agent-acties"
prompt = (
"Schrijf in het Nederlands (markdown) voor CEO Aïssa van Foodlinkk (halal kant-en-klaar maaltijden):\n\n"
"## Samenvatting\n(5-7 zinnen: wat is vandaag belangrijk, pipeline, retail kansen, milestones)\n\n"
"## Actiepunten vandaag — korte termijn\n(minimaal 5 concrete bullets met CRM/retail acties)\n\n"
"## Lange termijn focus\n(3-5 bullets: groei supermarkt partnerships, halal markt, milestones komende weken)\n\n"
"## Samenvatting\n(5-7 zinnen: pipeline, retail, IT ops, agent activiteit vandaag)\n\n"
"## Actiepunten vandaag — korte termijn\n(minimaal 5 bullets — incl. open goedkeuringen SysOps scan/backup)\n\n"
"## Lange termijn focus\n(3-5 bullets)\n\n"
"## Herman documentatie — wat er gebeurde\n(korte chronologische samenvatting van agent-acties, project assets, backups)\n\n"
f"Data vandaag ({data['date']}):\n"
f"- Pipeline €{data['pipeline_eur']:,.0f}, {data['clients']} klanten, {data['deals']} deals\n"
f"- {data.get('supermarkets',0)} supermarkten, {data.get('crm_partnerships',0)} actieve CRM partnerships\n"
f"- {data['pending_approvals']} goedkeuringen open\n"
f"- {data['pending_approvals']} goedkeuringen open in approval queue\n"
f"Activiteitenlog:\n{activity}\n"
f"Top kansen:\n{opp_lines or '- geen data'}\n"
f"Milestones open:\n{ms_lines or '- geen milestones'}\n"
)
system = (
"Je bent Herman, AI co-CEO van Foodlinkk. Schrijf warm, professioneel en actionable. "
"Focus op halal kant-en-klaar retail groei in Nederland. Geen vage tekst — concrete namen en acties."
"Je bent Herman, AI co-CEO van Foodlinkk. Documenteer en vat samen wat agents en IT hebben gedaan. "
"Noem expliciet openstaande SysOps scan/backup verzoeken als die in de log staan. "
"Schrijf warm, professioneel, actionable."
)
try:
return await ollama.generate(prompt, system=system, timeout=120.0)
@@ -327,10 +487,14 @@ def _fallback_summary(data: dict[str, Any]) -> str:
)
lines.extend(["", "## Actiepunten vandaag — korte termijn"])
actions = [
f"Keur {data['pending_approvals']} open agent-verzoeken goed (dashboard → Goedkeuringen)",
"Open Retail 360 en benader top-3 halal-gap filialen via CRM koppeling",
f"Behandel {data['pending_approvals']} openstaande agent-goedkeuringen",
"Check Marketing Live Feed voor kant-en-klaar trends",
]
for row in data.get("pending_approval_requests") or []:
if row.get("action_type") == "maintenance_scan":
actions.insert(0, f"**SysOps update-scan:** {row.get('title')} — keur goed op dashboard")
break
if ms:
actions.insert(0, f"Follow-up milestone: **{ms[0].get('title')}**")
for a in actions[:6]:
@@ -354,6 +518,18 @@ def _save_briefing(content: str, data: dict[str, Any]) -> None:
)
except Exception:
pass
try:
execute(
"""INSERT INTO llm_memory (category, subject, content, source, metadata, updated_at)
VALUES ('herman_daily', %s, %s, 'herman', %s::jsonb, NOW())""",
(
f"Briefing {data['date']}",
content[:8000],
json.dumps({"date": data["date"], "activity_count": len(data.get("activity_log") or [])}),
),
)
except Exception:
pass
try:
execute(
"""INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
@@ -361,7 +537,7 @@ def _save_briefing(content: str, data: dict[str, Any]) -> None:
(
"herman", "herman_delegate", "briefing",
f"CEO dagrapport {data['date']}", content[:2000],
"completed", "dashboard", json.dumps({"stats": safe}),
"completed", "dashboard", json.dumps({"stats": safe, "activity_log": data.get("activity_log", [])}),
),
)
except Exception:
+68
View File
@@ -5,6 +5,7 @@ import httpx
from app.config import settings
from app.db import execute, fetch_one
from app.services import ollama
from app.services import packaging_agent
AGENTS: dict[str, dict[str, str]] = {
"marketing": {"name": "Marketing", "persona": "Social, brand voice, campaigns for Foodlinkk."},
@@ -14,6 +15,7 @@ AGENTS: dict[str, dict[str, str]] = {
"product": {"name": "Product", "persona": "SKU development, launch timelines, shelf readiness."},
"halal": {"name": "Halal", "persona": "Halal compliance, certification, ingredient vetting."},
"design": {"name": "Design", "persona": "Packaging, visual identity, retail presentation."},
"packaging": {"name": "Packaging", "persona": "SVG/PDF verpakkingsontwerp, stanstekeningen, drukwerk."},
"knowledge": {"name": "Knowledge", "persona": "Internal docs, RAG, policy answers."},
}
@@ -82,6 +84,72 @@ def _pick_agent(raw: str) -> str:
return "knowledge"
async def chat(message: str) -> dict[str, Any]:
if packaging_agent.wants_packaging(message):
try:
outcome = await packaging_agent.generate_from_message(message)
name = outcome.get("design_name") or "Design"
pid = outcome.get("packaging_id", "")[:8]
reply_lines = [
f"Packaging agent heeft een design klaar voor je: {name}",
f"Type: {outcome.get('type')} · {outcome.get('dimensions')}",
f"Project #{outcome.get('cockpit_project_id')}",
f"Studio: {outcome.get('studio_url')}",
f"PDF: {outcome.get('pdf_url')}",
]
if outcome.get("nas", {}).get("ok"):
reply_lines.append("Bestanden staan op de NAS in de packaging-map van het project.")
reply = "\n".join(reply_lines)
await _log_event(
"packaging",
"packaging_created",
f"Design klaar: {name}",
f"Herman-opdracht: {message[:1500]}\n\nDesign-ID: {outcome.get('packaging_id')}",
{
"packaging_id": outcome.get("packaging_id"),
"project_id": outcome.get("cockpit_project_id"),
"studio_url": outcome.get("studio_url"),
"pdf_url": outcome.get("pdf_url"),
"nas": outcome.get("nas"),
"for_herman": True,
},
)
await _log_event(
"herman",
"packaging_delivered",
f"Packaging → Herman: {name}",
reply[:2000],
{
"source_agent": "packaging",
"packaging_id": outcome.get("packaging_id"),
"project_id": outcome.get("cockpit_project_id"),
},
)
return {
"agent": "packaging",
"agent_label": "Packaging → Herman",
"reply": reply,
"delegated_agents": ["packaging", "herman"],
"routing_reason": "Packaging-opdracht gedetecteerd — design gegenereerd en aan Herman gerapporteerd",
"packaging_id": outcome.get("packaging_id"),
"packaging_studio_url": outcome.get("studio_url"),
"packaging_pdf_url": outcome.get("pdf_url"),
}
except Exception as exc:
await _log_event(
"packaging",
"packaging_error",
"Packaging generatie mislukt",
str(exc)[:1500],
{"message": message[:500]},
)
return {
"agent": "packaging",
"agent_label": "Packaging",
"reply": f"Packaging agent kon geen design maken: {exc}",
"delegated_agents": ["packaging"],
}
if _wants_image(message):
prompt = _extract_image_prompt(message)
try:
+104
View File
@@ -0,0 +1,104 @@
"""NAS folder structure per client and project — geen alles-op-een-hoop."""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any, Optional
from app.db import execute, fetch_one
NAS_ROOT = Path(os.getenv("NAS_CLIENTS_ROOT", "/data/nas-clients"))
PROJECT_SUBDIRS = ("photos", "documents", "packaging", "exports", "briefs")
PROJECT_TYPES = [
{"id": "packaging", "label_nl": "Verpakking & label", "label_en": "Packaging & label", "icon": "📦", "nas_sub": "packaging"},
{"id": "retail_listing", "label_nl": "Retail listing / schap", "label_en": "Retail listing", "icon": "🏪", "nas_sub": "documents"},
{"id": "recipe", "label_nl": "Recept & productontwikkeling", "label_en": "Recipe & R&D", "icon": "🍱", "nas_sub": "briefs"},
{"id": "marketing", "label_nl": "Marketing campagne", "label_en": "Marketing campaign", "icon": "📣", "nas_sub": "exports"},
{"id": "halal", "label_nl": "Halal certificering", "label_en": "Halal certification", "icon": "☪️", "nas_sub": "documents"},
{"id": "sourcing", "label_nl": "Sourcing & import", "label_en": "Sourcing & import", "icon": "🚢", "nas_sub": "documents"},
{"id": "crm", "label_nl": "Klant & partnership", "label_en": "Client & partnership", "icon": "🤝", "nas_sub": "documents"},
{"id": "research", "label_nl": "Marktonderzoek", "label_en": "Market research", "icon": "🔬", "nas_sub": "briefs"},
{"id": "general", "label_nl": "Algemeen project", "label_en": "General project", "icon": "📁", "nas_sub": "documents"},
]
def slugify(name: str, max_len: int = 48) -> str:
s = re.sub(r"[^a-zA-Z0-9]+", "-", (name or "project").strip().lower()).strip("-")
return (s[:max_len] or "project")
def _write_meta(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
def ensure_client_folder(client_id: int, client_name: str) -> str:
"""Maak NAS-map per klant: clients/{slug}/ met submappen."""
slug = slugify(client_name)
root = NAS_ROOT / slug
for sub in ("projects", "photos", "documents", "inbox"):
(root / sub).mkdir(parents=True, exist_ok=True)
meta = {
"client_id": client_id,
"client_name": client_name,
"slug": slug,
"structure": ["projects", "photos", "documents", "inbox"],
}
_write_meta(root / "client.json", meta)
rel = str(root)
execute("UPDATE clients SET nas_folder = %s WHERE id = %s", (rel, client_id))
return rel
def ensure_project_folder(
project_id: int,
project_name: str,
client_id: Optional[int],
client_name: Optional[str],
project_type: str = "general",
) -> dict[str, Any]:
"""Projectmap onder klant: clients/{client}/projects/{project}/"""
client_root: Path | None = None
if client_id and client_name:
row = fetch_one("SELECT nas_folder FROM clients WHERE id = %s", (client_id,))
if row and row.get("nas_folder"):
client_root = Path(row["nas_folder"])
else:
client_root = Path(ensure_client_folder(client_id, client_name))
else:
client_root = NAS_ROOT / "_geen-klant"
client_root.mkdir(parents=True, exist_ok=True)
proj_slug = f"{project_id}-{slugify(project_name)}"
proj_root = client_root / "projects" / proj_slug
for sub in PROJECT_SUBDIRS:
(proj_root / sub).mkdir(parents=True, exist_ok=True)
type_info = next((t for t in PROJECT_TYPES if t["id"] == project_type), PROJECT_TYPES[-1])
_write_meta(
proj_root / "project.json",
{
"project_id": project_id,
"name": project_name,
"client_id": client_id,
"project_type": project_type,
"folders": list(PROJECT_SUBDIRS),
"primary_sub": type_info.get("nas_sub", "documents"),
},
)
nas_path = str(proj_root)
client_rel = str(client_root)
execute(
"""UPDATE cockpit_projects SET nas_path = %s, nas_client_root = %s, updated_at = NOW() WHERE id = %s""",
(nas_path, client_rel, project_id),
)
return {"nas_path": nas_path, "nas_client_root": client_rel, "project_slug": proj_slug}
def list_types() -> list[dict[str, Any]]:
return list(PROJECT_TYPES)
+232
View File
@@ -0,0 +1,232 @@
"""Packaging agent — parse Herman-opdrachten en genereer designs."""
from __future__ import annotations
import re
from typing import Any
import httpx
from app.config import settings
from app.services import packaging_nas, projects
PACKAGING_KEYWORDS = (
"maak verpakking",
"maak een verpakking",
"genereer verpakking",
"ontwerp verpakking",
"packaging:",
"packaging ",
"/packaging",
"maak packaging",
"maak package",
"stanstekening",
"verpakkingsontwerp",
)
TYPE_ALIASES: list[tuple[str, str]] = [
("folding box", "folding_box"),
("folding_box", "folding_box"),
("sluitdoos", "folding_box"),
("doos", "folding_box"),
("banderole", "wrap"),
("wrap", "wrap"),
("rond label", "round_label"),
("round label", "round_label"),
("round_label", "round_label"),
("sleeve", "sleeve"),
("huls", "sleeve"),
("pouch", "pouch"),
("zak", "pouch"),
("tray", "tray"),
("schaal", "tray"),
]
DEFAULT_ELEMENTS = {
"barcode": True,
"logo_area": True,
"fold_lines": True,
"cut_lines": True,
"nutrition_panel": False,
"ingredients": True,
"halal_badge": False,
"window": False,
"qr_code": False,
"glue_tabs": False,
"bleed": True,
"dimensions": True,
}
def wants_packaging(raw: str) -> bool:
t = (raw or "").strip().lower()
return any(k in t for k in PACKAGING_KEYWORDS)
def extract_packaging_body(raw: str) -> str:
t = raw.strip()
lower = t.lower()
for k in PACKAGING_KEYWORDS:
if lower.startswith(k):
rest = t[len(k) :].strip(" :,-")
if rest:
return rest
for k in PACKAGING_KEYWORDS:
if k in lower:
idx = lower.index(k) + len(k)
rest = t[idx:].strip(" :,-")
if rest:
return rest
return t
def _detect_type(text: str) -> str:
lower = text.lower()
for alias, ptype in TYPE_ALIASES:
if alias in lower:
return ptype
return "folding_box"
def _detect_dimensions(text: str) -> tuple[float, float, float]:
m = re.search(r"(\d{2,4})\s*[x×]\s*(\d{2,4})(?:\s*[x×]\s*(\d{1,4}))?", text, re.I)
if m:
w, h = float(m.group(1)), float(m.group(2))
d = float(m.group(3)) if m.group(3) else (40.0 if _detect_type(text) == "folding_box" else 20.0)
return w, h, d
return 120.0, 80.0, 40.0
def _detect_project_id(text: str) -> int | None:
m = re.search(r"project\s*#?\s*(\d+)", text, re.I)
if m:
return int(m.group(1))
m = re.search(r"\bproject\s+(\d+)\b", text, re.I)
return int(m.group(1)) if m else None
def _detect_product_name(text: str) -> str:
m = re.search(r'voor\s+["\']?([^"\']+?)["\']?(?:\s+project|\s*$|,)', text, re.I)
if m:
return m.group(1).strip()[:80]
m = re.search(r'product\s*[:=]\s*["\']?([^"\']+)["\']?', text, re.I)
if m:
return m.group(1).strip()[:80]
cleaned = text
for alias, _ in TYPE_ALIASES:
cleaned = re.sub(re.escape(alias), "", cleaned, flags=re.I)
cleaned = re.sub(r"\d{2,4}\s*[x×]\s*\d{2,4}(?:\s*[x×]\s*\d{1,4})?", "", cleaned, flags=re.I)
cleaned = re.sub(r"project\s*#?\s*\d+", "", cleaned, flags=re.I)
for kw in ("halal-badge", "halal badge", "voedingswaarden", "nutrition", "qr-code", "qr code", "venster", "window"):
cleaned = re.sub(re.escape(kw), "", cleaned, flags=re.I)
cleaned = cleaned.strip(" ,:-")
return (cleaned[:80] or "Foodlinkk Product")
def parse_packaging_request(message: str) -> dict[str, Any]:
body = extract_packaging_body(message)
lower = body.lower()
ptype = _detect_type(body)
w, h, d = _detect_dimensions(body)
product = _detect_product_name(body)
project_id = _detect_project_id(message) or _detect_project_id(body)
elements = dict(DEFAULT_ELEMENTS)
if any(k in lower for k in ("halal", "halal-badge", "halal badge")):
elements["halal_badge"] = True
if any(k in lower for k in ("voedingswaarden", "nutrition")):
elements["nutrition_panel"] = True
if any(k in lower for k in ("qr", "qrcode")):
elements["qr_code"] = True
if "venster" in lower or "window" in lower:
elements["window"] = True
return {
"type": ptype,
"width_mm": w,
"height_mm": h,
"depth_mm": d,
"bleed_mm": 3,
"design_name": product,
"barcode_value": "8710000000012",
"elements": elements,
"text": {
"product_name": product,
"tagline": "Premium halal kant-en-klaar",
"subtitle": "",
"ingredients": "",
"origin": "Geproduceerd in NL",
"best_before": "Ten minste houdbaar tot: zie verpakking",
},
"brand": {},
"project_id": project_id,
"created_by": "packaging",
}
async def _download_bytes(packaging_id: str, fmt: str) -> bytes | None:
try:
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.get(
f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/download/{packaging_id}",
params={"format": fmt},
)
if r.status_code < 400:
return r.content
except Exception:
pass
return None
async def generate_from_message(message: str) -> dict[str, Any]:
"""Genereer packaging design en exporteer naar NAS; retour voor Herman-reply."""
spec = parse_packaging_request(message)
if not spec.get("project_id"):
proj = projects.create_project(
f"Packaging · {spec['design_name'][:48]}",
description=f"Aangemaakt door packaging agent via Herman\n\nOpdracht: {message[:500]}",
project_type="packaging",
ensure_nas=True,
created_by="packaging",
)
spec["project_id"] = proj.get("id")
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.post(
f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/generate",
json=spec,
)
r.raise_for_status()
result = r.json()
packaging_id = result.get("id", "")
cockpit_project_id = result.get("cockpit_project_id") or spec.get("project_id")
svg = result.get("svg") or ""
saved_spec = result.get("spec") or spec
nas_info: dict[str, Any] = {}
if packaging_id and cockpit_project_id and svg:
png_b = await _download_bytes(packaging_id, "png")
pdf_b = await _download_bytes(packaging_id, "pdf")
try:
nas_info = packaging_nas.export_packaging_files(
packaging_id, int(cockpit_project_id), svg, saved_spec, png_b, pdf_b
)
except Exception as exc:
nas_info = {"ok": False, "error": str(exc)}
studio_url = f"/packaging?project_id={cockpit_project_id}"
pdf_url = f"/api/packaging/download/{packaging_id}?format=pdf"
return {
"packaging_id": packaging_id,
"cockpit_project_id": cockpit_project_id,
"design_name": saved_spec.get("design_name") or spec.get("design_name"),
"type": saved_spec.get("type"),
"dimensions": f"{saved_spec.get('width_mm')}×{saved_spec.get('height_mm')}×{saved_spec.get('depth_mm')} mm",
"studio_url": studio_url,
"pdf_url": pdf_url,
"nas": nas_info,
"spec": saved_spec,
"original_message": message,
}
+93
View File
@@ -0,0 +1,93 @@
"""Export packaging designs naar NAS projectmap."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from app.db import execute, fetch_one
from app.services import nas_folders
def _packaging_dir(project_id: int) -> Path | None:
row = fetch_one(
"""
SELECT p.id, p.name, p.nas_path, p.client_id, p.project_type, c.name AS client_name
FROM cockpit_projects p
LEFT JOIN clients c ON c.id = p.client_id
WHERE p.id = %s
""",
(project_id,),
)
if not row:
return None
nas_path = row.get("nas_path")
if not nas_path:
try:
paths = nas_folders.ensure_project_folder(
int(row["id"]),
row["name"] or f"project-{project_id}",
row.get("client_id"),
row.get("client_name"),
row.get("project_type") or "packaging",
)
nas_path = paths.get("nas_path")
except Exception:
return None
root = Path(nas_path) / "packaging"
root.mkdir(parents=True, exist_ok=True)
return root
def export_packaging_files(
packaging_id: str,
cockpit_project_id: int,
svg_content: str,
spec: dict[str, Any],
png_bytes: bytes | None = None,
pdf_bytes: bytes | None = None,
) -> dict[str, Any]:
"""Schrijf SVG/PNG/PDF + manifest naar NAS packaging/ submap."""
root = _packaging_dir(cockpit_project_id)
if not root:
return {"ok": False, "error": "Geen NAS-map voor project"}
design_name = spec.get("design_name") or spec.get("text", {}).get("product_name") or packaging_id[:8]
slug = nas_folders.slugify(str(design_name))[:32]
base = f"{packaging_id[:8]}-{slug}"
paths: dict[str, str] = {}
svg_path = root / f"{base}.svg"
svg_path.write_text(svg_content, encoding="utf-8")
paths["svg"] = str(svg_path)
if png_bytes:
png_path = root / f"{base}.png"
png_path.write_bytes(png_bytes)
paths["png"] = str(png_path)
if pdf_bytes:
pdf_path = root / f"{base}.pdf"
pdf_path.write_bytes(pdf_bytes)
paths["pdf"] = str(pdf_path)
manifest = {
"packaging_id": packaging_id,
"design_name": design_name,
"spec": spec,
"files": paths,
}
manifest_path = root / f"{base}.json"
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
paths["manifest"] = str(manifest_path)
primary = paths.get("pdf") or paths.get("svg")
execute(
"""
UPDATE project_assets SET file_path = %s
WHERE project_id = %s AND asset_type = 'packaging' AND ref_id = %s
""",
(primary, cockpit_project_id, packaging_id),
)
return {"ok": True, "nas_packaging_dir": str(root), "files": paths}
+234
View File
@@ -0,0 +1,234 @@
"""Unified cockpit projects and cross-module assets."""
from __future__ import annotations
import json
from typing import Any, Optional
from app.db import execute, fetch_all, fetch_one
from app.services import nas_folders
def _row(row: dict | None) -> dict | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
return out
def list_projects(client_id: Optional[int] = None, limit: int = 50) -> list[dict[str, Any]]:
clauses, params = [], []
if client_id:
clauses.append("p.client_id = %s")
params.append(client_id)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
safe = max(1, min(limit, 200))
rows = fetch_all(
f"""
SELECT p.*, c.name AS client_name,
(SELECT COUNT(*) FROM project_assets a WHERE a.project_id = p.id) AS asset_count
FROM cockpit_projects p
LEFT JOIN clients c ON c.id = p.client_id
{where}
ORDER BY p.updated_at DESC, p.id DESC
LIMIT %s
""",
tuple(params + [safe]),
)
return [_row(r) for r in rows]
def get_project(project_id: int) -> dict[str, Any] | None:
row = fetch_one(
"""
SELECT p.*, c.name AS client_name
FROM cockpit_projects p
LEFT JOIN clients c ON c.id = p.client_id
WHERE p.id = %s
""",
(project_id,),
)
if not row:
return None
out = _row(row) or {}
assets = fetch_all(
"SELECT * FROM project_assets WHERE project_id = %s ORDER BY created_at DESC",
(project_id,),
)
out["assets"] = [_row(a) for a in assets]
return out
def create_project(
name: str,
client_id: Optional[int] = None,
description: str = "",
created_by: str = "ceo",
metadata: dict | None = None,
project_type: str = "general",
priority: str = "normal",
ensure_nas: bool = True,
) -> dict[str, Any]:
row = fetch_one(
"""
INSERT INTO cockpit_projects (name, client_id, description, created_by, metadata, project_type, priority, updated_at)
VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s, NOW())
RETURNING *
""",
(name.strip(), client_id, description or None, created_by, json.dumps(metadata or {}), project_type, priority),
)
out = _row(row) or {}
if ensure_nas and out.get("id"):
client_name = None
if client_id:
c = fetch_one("SELECT name FROM clients WHERE id = %s", (client_id,))
client_name = c["name"] if c else None
try:
paths = nas_folders.ensure_project_folder(
int(out["id"]), name, client_id, client_name, project_type
)
out.update(paths)
except Exception as exc:
out["nas_error"] = str(exc)
return out
def update_project(project_id: int, **fields: Any) -> dict[str, Any] | None:
allowed = ("name", "description", "status", "client_id", "project_type", "priority")
sets, params = [], []
for k, v in fields.items():
if k in allowed and v is not None:
sets.append(f"{k} = %s")
params.append(v)
if not sets:
return get_project(project_id)
params.append(project_id)
execute(f"UPDATE cockpit_projects SET {', '.join(sets)}, updated_at = NOW() WHERE id = %s", tuple(params))
return get_project(project_id)
def project_stats() -> dict[str, Any]:
total = fetch_one("SELECT COUNT(*) AS n FROM cockpit_projects") or {"n": 0}
by_type = fetch_all(
"SELECT COALESCE(project_type, 'general') AS t, COUNT(*) AS n FROM cockpit_projects GROUP BY t"
)
with_nas = fetch_one("SELECT COUNT(*) AS n FROM cockpit_projects WHERE nas_path IS NOT NULL")
with_client = fetch_one("SELECT COUNT(*) AS n FROM cockpit_projects WHERE client_id IS NOT NULL")
assets = fetch_one("SELECT COUNT(*) AS n FROM project_assets")
return {
"total": int(total.get("n") or 0),
"with_nas": int((with_nas or {}).get("n") or 0),
"with_client": int((with_client or {}).get("n") or 0),
"assets": int((assets or {}).get("n") or 0),
"by_type": {r["t"]: int(r["n"]) for r in by_type},
}
def add_asset(
project_id: int,
asset_type: str,
title: str,
ref_id: str | None = None,
file_path: str | None = None,
payload: dict | None = None,
created_by: str = "ceo",
source_agent: str | None = None,
) -> dict[str, Any]:
row = fetch_one(
"""
INSERT INTO project_assets (project_id, asset_type, ref_id, title, file_path, payload, created_by, source_agent)
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s)
RETURNING *
""",
(
project_id,
asset_type,
ref_id,
title,
file_path,
json.dumps(payload or {}),
created_by,
source_agent,
),
)
execute("UPDATE cockpit_projects SET updated_at = NOW() WHERE id = %s", (project_id,))
return _row(row) or {}
def get_or_create_agent_project(agent_key: str, client_id: Optional[int] = None) -> int:
key = (agent_key or "agent").strip().lower()
row = fetch_one(
"""
SELECT id FROM cockpit_projects
WHERE metadata->>'auto_agent' = %s AND status = 'active'
ORDER BY updated_at DESC LIMIT 1
""",
(key,),
)
if row:
return int(row["id"])
created = create_project(
f"Agent · {key}",
client_id=client_id,
description=f"Automatisch project voor {key} output",
created_by=key,
metadata={"auto_agent": key},
)
return int(created["id"])
def register_agent_output(
asset_type: str,
title: str,
ref_id: str | None = None,
payload: dict | None = None,
project_id: Optional[int] = None,
source_agent: str | None = None,
created_by: str = "ceo",
file_path: str | None = None,
) -> dict[str, Any] | None:
agent = (source_agent or created_by or "agent").strip().lower()
pid = project_id or get_or_create_agent_project(agent)
if ref_id:
existing = fetch_one(
"""
SELECT id FROM project_assets
WHERE project_id = %s AND asset_type = %s AND ref_id = %s
LIMIT 1
""",
(pid, asset_type, ref_id),
)
if existing:
return {"id": existing["id"], "project_id": pid, "dedup": True}
return add_asset(
pid,
asset_type,
title,
ref_id=ref_id,
file_path=file_path,
payload=payload,
created_by=created_by or agent,
source_agent=agent,
)
def link_photo_to_project(photo_id: int, project_id: int, created_by: str = "ceo") -> None:
photo = fetch_one("SELECT id, filename, source FROM photo_imports WHERE id = %s", (photo_id,))
if not photo:
raise ValueError("Photo not found")
execute(
"UPDATE photo_imports SET project_id = %s, created_by = COALESCE(created_by, %s) WHERE id = %s",
(project_id, created_by, photo_id),
)
add_asset(
project_id,
"photo",
photo.get("filename") or f"Foto #{photo_id}",
ref_id=str(photo_id),
created_by=created_by,
source_agent=photo.get("source"),
)
+30 -2
View File
@@ -244,7 +244,14 @@ def test_connection(platform: str, integration: dict[str, Any] | None = None) ->
return {"ok": True, "status": "ok", "message": f"{platform} configuration is present"}
def run_publish_job(job_id: int, text: str, channels: list[str], image_url: str | None, media_ids: list[int]) -> None:
def run_publish_job(
job_id: int,
text: str,
channels: list[str],
image_url: str | None,
media_ids: list[int],
project_id: int | None = None,
) -> None:
started_at = datetime.utcnow()
execute(
"UPDATE social_publish_jobs SET status=%s, started_at=NOW(), updated_at=NOW() WHERE id=%s",
@@ -310,5 +317,26 @@ def run_publish_job(job_id: int, text: str, channels: list[str], image_url: str
title=f"Social publish job #{job_id} afgerond",
body=f"Published={published}, skipped={skipped}, failed={failed}",
status=final_status,
metadata={"job_id": job_id, "results": results},
metadata={"job_id": job_id, "results": results, "project_id": project_id},
)
try:
from app.services import projects as project_svc
project_svc.register_agent_output(
asset_type="social_publish",
title=f"Social publish #{job_id} · {final_status}",
ref_id=str(job_id),
payload={
"job_id": job_id,
"status": final_status,
"channels": channels,
"published": published,
"text_preview": (text or "")[:200],
},
project_id=project_id,
source_agent="marketing",
created_by="marketing_automation",
)
except Exception:
pass
+82
View File
@@ -0,0 +1,82 @@
"""User UI preferences — dashboard layout and visualization modes."""
from __future__ import annotations
import json
from typing import Any
from app.db import execute, fetch_one
DEFAULT_LAYOUT = ["kpis", "executive", "briefing", "retail", "analytics", "approvals", "feed"]
DEFAULT_VIZ = "neo-bars"
VIZ_MODES = [
{"id": "neo-bars", "label": "Neo staafdiagram", "icon": "📊"},
{"id": "neo-rings", "label": "Neo ringen", "icon": "🍩"},
{"id": "neo-equalizer", "label": "Neo equalizer", "icon": "🎚️"},
{"id": "neo-cards", "label": "Neo kaarten", "icon": "🃏"},
{"id": "neo-table", "label": "Neo tabel", "icon": "📋"},
]
def get_preferences(user_key: str = "ceo") -> dict[str, Any]:
row = fetch_one("SELECT * FROM user_ui_preferences WHERE user_key = %s", (user_key,))
if not row:
return {
"user_key": user_key,
"dashboard_layout": DEFAULT_LAYOUT,
"viz_modes": {},
"global_viz_mode": DEFAULT_VIZ,
"locale": "nl",
"viz_options": VIZ_MODES,
}
layout = row.get("dashboard_layout") or DEFAULT_LAYOUT
if isinstance(layout, str):
try:
layout = json.loads(layout)
except Exception:
layout = DEFAULT_LAYOUT
viz_modes = row.get("viz_modes") or {}
if isinstance(viz_modes, str):
try:
viz_modes = json.loads(viz_modes)
except Exception:
viz_modes = {}
return {
"user_key": user_key,
"dashboard_layout": layout,
"viz_modes": viz_modes,
"global_viz_mode": row.get("global_viz_mode") or DEFAULT_VIZ,
"locale": row.get("locale") or "nl",
"viz_options": VIZ_MODES,
}
def save_preferences(
user_key: str,
dashboard_layout: list[str] | None = None,
global_viz_mode: str | None = None,
viz_modes: dict[str, str] | None = None,
locale: str | None = None,
) -> dict[str, Any]:
current = get_preferences(user_key)
layout = dashboard_layout if dashboard_layout is not None else current["dashboard_layout"]
gviz = global_viz_mode if global_viz_mode is not None else current["global_viz_mode"]
vmodes = viz_modes if viz_modes is not None else current["viz_modes"]
loc = locale if locale is not None else current.get("locale", "nl")
if loc not in ("nl", "en"):
loc = "nl"
fetch_one(
"""
INSERT INTO user_ui_preferences (user_key, dashboard_layout, global_viz_mode, viz_modes, locale, updated_at)
VALUES (%s, %s::jsonb, %s, %s::jsonb, %s, NOW())
ON CONFLICT (user_key) DO UPDATE SET
dashboard_layout = EXCLUDED.dashboard_layout,
global_viz_mode = EXCLUDED.global_viz_mode,
viz_modes = EXCLUDED.viz_modes,
locale = EXCLUDED.locale,
updated_at = NOW()
RETURNING user_key
""",
(user_key, json.dumps(layout), gviz, json.dumps(vmodes), loc),
)
return get_preferences(user_key)