SysOps: deploy-all — 2026-06-09 10:41 UTC
This commit is contained in:
@@ -36,6 +36,7 @@ from app.routes.admin_api import admin_router, ai_router, herman_api, voice_api
|
||||
from app.routes.settings_api import settings_router
|
||||
from app.routes.agents_api import router as agents_api_router
|
||||
from app.routes.marketing_api import router as marketing_api_router
|
||||
from app.routes.projects_api import router as projects_api_router
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
@@ -74,6 +75,7 @@ for r in (
|
||||
settings_router,
|
||||
agents_api_router,
|
||||
marketing_api_router,
|
||||
projects_api_router,
|
||||
):
|
||||
app.include_router(r)
|
||||
|
||||
|
||||
@@ -168,7 +168,15 @@ class DelegateBody(BaseModel):
|
||||
@admin_router.get("/clients")
|
||||
def list_clients():
|
||||
return {"items": _serialize_rows(fetch_all(
|
||||
"SELECT * FROM clients ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 500"
|
||||
"""SELECT c.*,
|
||||
(SELECT COUNT(*) FROM client_supermarket_links l WHERE l.client_id = c.id)
|
||||
+ (SELECT COUNT(*) FROM supermarkets s WHERE s.client_id = c.id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM client_supermarket_links l2
|
||||
WHERE l2.supermarket_id = s.id AND l2.client_id = c.id
|
||||
)) AS store_count
|
||||
FROM clients c
|
||||
ORDER BY c.updated_at DESC NULLS LAST, c.created_at DESC LIMIT 500"""
|
||||
))}
|
||||
|
||||
|
||||
@@ -199,6 +207,79 @@ def delete_client(client_id: int):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@admin_router.get("/clients/stats")
|
||||
def clients_stats():
|
||||
stats: dict[str, Any] = {}
|
||||
try:
|
||||
rows = fetch_all("SELECT stage, COUNT(*) AS n FROM clients GROUP BY stage")
|
||||
stats["by_stage"] = {r["stage"]: int(r["n"]) for r in rows}
|
||||
stats["total"] = sum(stats["by_stage"].values())
|
||||
stats["active"] = stats["by_stage"].get("active", 0)
|
||||
row = fetch_one("SELECT COALESCE(SUM(mrr_estimate),0) AS s FROM clients WHERE stage NOT IN ('churned')")
|
||||
stats["mrr_total"] = float(row["s"] or 0) if row else 0
|
||||
row = fetch_one(
|
||||
"""SELECT COALESCE(SUM(d.value),0) AS s FROM deals d
|
||||
JOIN clients c ON c.id = d.client_id
|
||||
WHERE d.stage NOT IN ('won','lost')"""
|
||||
)
|
||||
stats["pipeline_value"] = float(row["s"] or 0) if row else 0
|
||||
row = fetch_one("SELECT COUNT(*) AS n FROM client_supermarket_links")
|
||||
stats["store_links"] = int(row["n"] or 0) if row else 0
|
||||
row = fetch_one("SELECT COUNT(*) AS n FROM deals")
|
||||
stats["deals"] = int(row["n"] or 0) if row else 0
|
||||
except Exception:
|
||||
stats = {"total": 0, "active": 0, "mrr_total": 0, "pipeline_value": 0, "store_links": 0, "deals": 0, "by_stage": {}}
|
||||
return {"ok": True, "stats": stats}
|
||||
|
||||
|
||||
@admin_router.get("/clients/{client_id}/detail")
|
||||
def client_detail(client_id: int):
|
||||
client = fetch_one("SELECT * FROM clients WHERE id = %s", (client_id,))
|
||||
if not client:
|
||||
raise HTTPException(404, "Client not found")
|
||||
deals = fetch_all(
|
||||
"""SELECT id, title, value, stage, next_action, deadline, updated_at
|
||||
FROM deals WHERE client_id = %s ORDER BY updated_at DESC NULLS LAST LIMIT 20""",
|
||||
(client_id,),
|
||||
)
|
||||
stores = fetch_all(
|
||||
"""
|
||||
SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, s.phone, s.email,
|
||||
s.partnership_status, s.halal_certified, s.has_halal_section, s.manager_name,
|
||||
l.relationship_type, l.notes AS link_notes, l.deal_id, l.created_at AS linked_at
|
||||
FROM client_supermarket_links l
|
||||
JOIN supermarkets s ON s.id = l.supermarket_id
|
||||
WHERE l.client_id = %s
|
||||
ORDER BY s.chain, s.city, s.name
|
||||
""",
|
||||
(client_id,),
|
||||
)
|
||||
direct = fetch_all(
|
||||
"""
|
||||
SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, s.phone, s.email,
|
||||
s.partnership_status, s.halal_certified, s.has_halal_section, s.manager_name,
|
||||
'direct' AS relationship_type, NULL AS link_notes, s.deal_id, s.last_updated AS linked_at
|
||||
FROM supermarkets s
|
||||
WHERE s.client_id = %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM client_supermarket_links l
|
||||
WHERE l.supermarket_id = s.id AND l.client_id = s.client_id
|
||||
)
|
||||
ORDER BY s.chain, s.city, s.name
|
||||
""",
|
||||
(client_id,),
|
||||
)
|
||||
merged: dict[int, dict] = {}
|
||||
for row in list(stores) + list(direct):
|
||||
merged[int(row["id"])] = row
|
||||
return {
|
||||
"ok": True,
|
||||
"client": _serialize(client),
|
||||
"deals": _serialize_rows(deals),
|
||||
"stores": _serialize_rows(list(merged.values())),
|
||||
}
|
||||
|
||||
|
||||
# --- Deals CRUD ---
|
||||
|
||||
@admin_router.get("/deals")
|
||||
@@ -239,6 +320,36 @@ def delete_deal(deal_id: int):
|
||||
|
||||
# --- Products CRUD ---
|
||||
|
||||
@admin_router.get("/products/stats")
|
||||
def products_stats():
|
||||
stats: dict[str, Any] = {}
|
||||
try:
|
||||
row = fetch_one("SELECT COUNT(*) AS n FROM products")
|
||||
stats["total"] = int(row["n"] or 0) if row else 0
|
||||
rows = fetch_all("SELECT status, COUNT(*) AS n FROM products GROUP BY status")
|
||||
stats["by_status"] = {r["status"]: int(r["n"]) for r in rows}
|
||||
stats["active"] = stats["by_status"].get("active", 0)
|
||||
row = fetch_one("SELECT AVG(margin_pct) AS a FROM products WHERE margin_pct IS NOT NULL")
|
||||
stats["avg_margin"] = round(float(row["a"] or 0), 1) if row else 0
|
||||
row = fetch_one("SELECT COUNT(*) AS n FROM products WHERE client_id IS NOT NULL")
|
||||
stats["with_client"] = int(row["n"] or 0) if row else 0
|
||||
except Exception:
|
||||
stats = {"total": 0, "active": 0, "avg_margin": 0, "with_client": 0, "by_status": {}}
|
||||
return {"ok": True, "stats": stats}
|
||||
|
||||
|
||||
@admin_router.get("/products/{product_id}/detail")
|
||||
def product_detail(product_id: int):
|
||||
product = fetch_one(
|
||||
"""SELECT p.*, c.name AS client_name, c.email AS client_email, c.stage AS client_stage
|
||||
FROM products p LEFT JOIN clients c ON c.id = p.client_id WHERE p.id = %s""",
|
||||
(product_id,),
|
||||
)
|
||||
if not product:
|
||||
raise HTTPException(404, "Product not found")
|
||||
return {"ok": True, "product": _serialize(product)}
|
||||
|
||||
|
||||
@admin_router.get("/products")
|
||||
def list_products():
|
||||
return {"items": _serialize_rows(fetch_all(
|
||||
@@ -284,6 +395,35 @@ def margin_calc(body: MarginBody):
|
||||
|
||||
# --- Suppliers CRUD ---
|
||||
|
||||
@admin_router.get("/suppliers/stats")
|
||||
def suppliers_stats():
|
||||
stats: dict[str, Any] = {}
|
||||
try:
|
||||
row = fetch_one("SELECT COUNT(*) AS n FROM suppliers")
|
||||
stats["total"] = int(row["n"] or 0) if row else 0
|
||||
row = fetch_one("SELECT AVG(rating) AS a FROM suppliers WHERE rating IS NOT NULL")
|
||||
stats["avg_rating"] = round(float(row["a"] or 0), 1) if row else 0
|
||||
row = fetch_one("SELECT COUNT(DISTINCT country) AS n FROM suppliers WHERE country IS NOT NULL AND country <> ''")
|
||||
stats["countries"] = int(row["n"] or 0) if row else 0
|
||||
row = fetch_one("SELECT AVG(lead_time_days) AS a FROM suppliers WHERE lead_time_days IS NOT NULL")
|
||||
stats["avg_lead"] = round(float(row["a"] or 0), 0) if row else 0
|
||||
rows = fetch_all(
|
||||
"SELECT COALESCE(category, 'Overig') AS category, COUNT(*) AS n FROM suppliers GROUP BY category ORDER BY n DESC LIMIT 8"
|
||||
)
|
||||
stats["by_category"] = {r["category"]: int(r["n"]) for r in rows}
|
||||
except Exception:
|
||||
stats = {"total": 0, "avg_rating": 0, "countries": 0, "avg_lead": 0, "by_category": {}}
|
||||
return {"ok": True, "stats": stats}
|
||||
|
||||
|
||||
@admin_router.get("/suppliers/{supplier_id}/detail")
|
||||
def supplier_detail(supplier_id: int):
|
||||
supplier = fetch_one("SELECT * FROM suppliers WHERE id = %s", (supplier_id,))
|
||||
if not supplier:
|
||||
raise HTTPException(404, "Supplier not found")
|
||||
return {"ok": True, "supplier": _serialize(supplier)}
|
||||
|
||||
|
||||
@admin_router.get("/suppliers")
|
||||
def list_suppliers():
|
||||
return {"items": _serialize_rows(fetch_all("SELECT * FROM suppliers ORDER BY name LIMIT 500"))}
|
||||
|
||||
@@ -12,7 +12,7 @@ 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": "Herman", "role": "Co-CEO · Takenverdeler", "color": "cyan"},
|
||||
{"name": "Sales", "role": "Pipeline", "color": "purple"},
|
||||
{"name": "Marketing", "role": "Social & Content", "color": "amber"},
|
||||
{"name": "Ops", "role": "Operations", "color": "green"},
|
||||
@@ -22,7 +22,7 @@ AGENT_STATUSES = [
|
||||
@router.get("")
|
||||
async def agents_page(request: Request):
|
||||
active_tab = request.query_params.get("tab", "souls")
|
||||
if active_tab not in {"souls", "mesh"}:
|
||||
if active_tab not in {"souls", "mesh", "approvals"}:
|
||||
active_tab = "souls"
|
||||
events: list = []
|
||||
try:
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db import fetch_all
|
||||
from app.services import agent_souls
|
||||
from app.services import agent_approvals, agent_souls
|
||||
|
||||
router = APIRouter(prefix="/api/agents", tags=["agents-api"])
|
||||
|
||||
@@ -20,6 +20,21 @@ class SoulUpdate(BaseModel):
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class ActionRequestBody(BaseModel):
|
||||
agent_key: str
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
action_type: str = "query"
|
||||
query_payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RejectBody(BaseModel):
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class ExecuteBody(BaseModel):
|
||||
result: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.get("/souls")
|
||||
def api_list_souls() -> dict[str, Any]:
|
||||
return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())}
|
||||
@@ -33,6 +48,15 @@ def api_get_soul(agent_key: str) -> dict[str, Any]:
|
||||
return {"soul": soul}
|
||||
|
||||
|
||||
@router.get("/souls/{agent_key}/events")
|
||||
def api_list_agent_events(agent_key: str, limit: int = 50) -> dict[str, Any]:
|
||||
soul = agent_souls.get_soul(agent_key)
|
||||
if not soul:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
items = agent_souls.list_agent_events(agent_key, limit=min(limit, 100))
|
||||
return {"agent_key": agent_key.lower(), "items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.put("/souls/{agent_key}")
|
||||
def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -92,4 +116,111 @@ def api_agents_mesh() -> dict[str, Any]:
|
||||
"""
|
||||
)
|
||||
edges = [{"source": str(r["source_key"]), "target": "herman", "weight": int(r["weight"])} for r in edge_rows]
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
return {"nodes": nodes, "edges": edges, "executives": [{"id": "ceo", "label": "CEO", "role": "Aissa"}, {"id": "cto", "label": "CTO", "role": "Platform"}]}
|
||||
|
||||
|
||||
@router.get("/approvals")
|
||||
def api_list_approvals(status: Optional[str] = None, limit: int = 50) -> dict[str, Any]:
|
||||
try:
|
||||
items = agent_approvals.list_requests(status=status, limit=limit)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.post("/requests")
|
||||
def api_create_action_request(body: ActionRequestBody) -> dict[str, Any]:
|
||||
try:
|
||||
req = agent_approvals.create_request(
|
||||
body.agent_key,
|
||||
body.title,
|
||||
action_type=body.action_type,
|
||||
query_payload=body.query_payload,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return {"ok": True, "request": req, "message": "Wacht op goedkeuring voordat query uitgevoerd mag worden"}
|
||||
|
||||
|
||||
@router.post("/approvals/{request_id}/approve")
|
||||
def api_approve_request(request_id: int) -> dict[str, Any]:
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
req = agent_approvals.approve_request(request_id, approved_by="ceo")
|
||||
action = req.get("action_type") or ""
|
||||
auto_result = None
|
||||
|
||||
if action in ("config_backup", "maintenance_scan"):
|
||||
tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
|
||||
path = "/ops/backup/run" if action == "config_backup" else "/ops/maintenance/scan"
|
||||
payload = {"approval_request_id": request_id} if action == "config_backup" else None
|
||||
with httpx.Client(timeout=180.0) as client:
|
||||
resp = client.post(f"{tools_url}{path}", json=payload or {})
|
||||
try:
|
||||
auto_result = resp.json()
|
||||
except Exception:
|
||||
auto_result = {"ok": False, "detail": resp.text}
|
||||
req = agent_approvals.mark_executed(request_id, auto_result or {})
|
||||
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return {"ok": True, "request": req, "executed": auto_result is not None, "result": auto_result}
|
||||
|
||||
|
||||
@router.post("/approvals/{request_id}/reject")
|
||||
def api_reject_request(request_id: int, body: RejectBody) -> dict[str, Any]:
|
||||
try:
|
||||
req = agent_approvals.reject_request(request_id, reason=body.reason)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"ok": True, "request": req}
|
||||
|
||||
|
||||
@router.post("/approvals/{request_id}/execute")
|
||||
def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str, Any]:
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
req = agent_approvals.require_approved(request_id)
|
||||
result_payload = body.result or {}
|
||||
action = req.get("action_type") or ""
|
||||
|
||||
if action == "config_backup":
|
||||
tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
|
||||
with httpx.Client(timeout=180.0) as client:
|
||||
resp = client.post(
|
||||
f"{tools_url}/ops/backup/run",
|
||||
json={"approval_request_id": request_id},
|
||||
)
|
||||
try:
|
||||
result_payload = resp.json()
|
||||
except Exception:
|
||||
result_payload = {"ok": False, "detail": resp.text}
|
||||
if resp.status_code >= 400:
|
||||
raise HTTPException(status_code=502, detail=result_payload.get("detail") or resp.text)
|
||||
|
||||
elif action == "maintenance_scan":
|
||||
tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
|
||||
with httpx.Client(timeout=120.0) as client:
|
||||
resp = client.post(f"{tools_url}/ops/maintenance/scan")
|
||||
result_payload = resp.json() if resp.status_code < 400 else {"ok": False, "detail": resp.text}
|
||||
|
||||
req = agent_approvals.mark_executed(request_id, result_payload)
|
||||
except HTTPException:
|
||||
raise
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return {"ok": True, "request": req}
|
||||
|
||||
@@ -35,6 +35,7 @@ class PublishRequest(BaseModel):
|
||||
image_url: str | None = None
|
||||
media_ids: list[int] = Field(default_factory=list)
|
||||
channels: list[str] = Field(default_factory=list)
|
||||
project_id: int | None = None
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
@@ -91,7 +92,9 @@ def create_publish_job(body: PublishRequest, background_tasks: BackgroundTasks)
|
||||
),
|
||||
)
|
||||
job_id = int(row["id"])
|
||||
background_tasks.add_task(run_publish_job, job_id, body.text, channels, body.image_url, body.media_ids)
|
||||
background_tasks.add_task(
|
||||
run_publish_job, job_id, body.text, channels, body.image_url, body.media_ids, body.project_id,
|
||||
)
|
||||
return {"job_id": job_id, "status": "queued", "channels": channels}
|
||||
|
||||
|
||||
|
||||
+201
-40
@@ -1,18 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
from app.services import packaging_nas, projects
|
||||
|
||||
router = APIRouter(tags=["packaging"])
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
TOOLS = settings.TOOLS_API_URL.rstrip("/")
|
||||
|
||||
|
||||
class PackagingGenerateBody(BaseModel):
|
||||
@@ -20,68 +23,226 @@ class PackagingGenerateBody(BaseModel):
|
||||
width_mm: float = Field(default=120, gt=0)
|
||||
height_mm: float = Field(default=80, gt=0)
|
||||
depth_mm: float = Field(default=40, ge=0)
|
||||
bleed_mm: float = Field(default=3, 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)
|
||||
text: dict[str, Any] = Field(default_factory=dict)
|
||||
barcode_value: str | None = None
|
||||
qr_value: str | None = None
|
||||
design_name: str | None = None
|
||||
project_id: int | None = None
|
||||
project_name: str | None = None
|
||||
client_id: int | None = None
|
||||
created_by: str = "ceo"
|
||||
|
||||
|
||||
class PackagingEmailBody(BaseModel):
|
||||
to: list[str] = Field(..., min_length=1)
|
||||
subject: str = Field(..., min_length=1)
|
||||
body: str = ""
|
||||
cc: list[str] = Field(default_factory=list)
|
||||
client_id: int | None = None
|
||||
|
||||
|
||||
class CopyToProjectBody(BaseModel):
|
||||
target_project_id: int
|
||||
|
||||
|
||||
async def _tools_request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
r = await client.request(method, f"{TOOLS}{path}", **kwargs)
|
||||
return r
|
||||
|
||||
|
||||
def _tools_error(exc: httpx.HTTPStatusError) -> HTTPException:
|
||||
detail = exc.response.text[:500] if exc.response else str(exc)
|
||||
return HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail)
|
||||
|
||||
|
||||
async def _ensure_project(body: PackagingGenerateBody) -> PackagingGenerateBody:
|
||||
if body.project_id or not body.project_name:
|
||||
return body
|
||||
proj = projects.create_project(
|
||||
body.project_name.strip(),
|
||||
client_id=body.client_id,
|
||||
description="Packaging design project",
|
||||
project_type="packaging",
|
||||
ensure_nas=True,
|
||||
)
|
||||
data = body.model_dump()
|
||||
data["project_id"] = proj.get("id")
|
||||
return PackagingGenerateBody(**data)
|
||||
|
||||
|
||||
async def _post_nas_export(result: dict[str, Any]) -> dict[str, Any]:
|
||||
pid = result.get("cockpit_project_id")
|
||||
packaging_id = result.get("id")
|
||||
svg = result.get("svg") or ""
|
||||
spec = result.get("spec") or {}
|
||||
if not pid or not packaging_id or not svg:
|
||||
return result
|
||||
png_bytes = pdf_bytes = None
|
||||
try:
|
||||
png_r = await _tools_request("GET", f"/packaging/download/{packaging_id}", params={"format": "png"})
|
||||
if png_r.status_code < 400:
|
||||
png_bytes = png_r.content
|
||||
pdf_r = await _tools_request("GET", f"/packaging/download/{packaging_id}", params={"format": "pdf"})
|
||||
if pdf_r.status_code < 400:
|
||||
pdf_bytes = pdf_r.content
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
nas_info = packaging_nas.export_packaging_files(packaging_id, int(pid), svg, spec, png_bytes, pdf_bytes)
|
||||
result["nas"] = nas_info
|
||||
except Exception as exc:
|
||||
result["nas"] = {"ok": False, "error": str(exc)}
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/packaging")
|
||||
async def packaging_page(request: Request):
|
||||
async def packaging_page(request: Request, project_id: Optional[int] = None):
|
||||
return templates.TemplateResponse(
|
||||
"packaging.html",
|
||||
{"request": request, "page_title": "Packaging Studio"},
|
||||
{"request": request, "page_title": "Packaging Studio", "initial_project_id": project_id},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/packaging/types")
|
||||
async def proxy_packaging_types():
|
||||
try:
|
||||
r = await _tools_request("GET", "/packaging/types")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise _tools_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/api/packaging/preview")
|
||||
async def proxy_packaging_preview(body: PackagingGenerateBody):
|
||||
try:
|
||||
r = await _tools_request("POST", "/packaging/preview", json=body.model_dump())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise _tools_error(exc) from exc
|
||||
|
||||
|
||||
@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()
|
||||
body = await _ensure_project(body)
|
||||
r = await _tools_request("POST", "/packaging/generate", json=body.model_dump())
|
||||
r.raise_for_status()
|
||||
result = r.json()
|
||||
return await _post_nas_export(result)
|
||||
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
|
||||
raise _tools_error(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()
|
||||
r = await _tools_request("GET", "/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
|
||||
raise _tools_error(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)
|
||||
r = await _tools_request("GET", f"/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
|
||||
raise _tools_error(exc) from exc
|
||||
|
||||
|
||||
@router.get("/api/packaging/{packaging_id}")
|
||||
async def proxy_packaging_get(packaging_id: str):
|
||||
try:
|
||||
r = await _tools_request("GET", f"/packaging/{packaging_id}")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise _tools_error(exc) from exc
|
||||
|
||||
|
||||
@router.patch("/api/packaging/{packaging_id}")
|
||||
async def proxy_packaging_update(packaging_id: str, body: PackagingGenerateBody):
|
||||
try:
|
||||
r = await _tools_request("PATCH", f"/packaging/{packaging_id}", json={"spec": body.model_dump()})
|
||||
r.raise_for_status()
|
||||
result = r.json()
|
||||
return await _post_nas_export(result)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise _tools_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/api/packaging/{packaging_id}/duplicate")
|
||||
async def proxy_packaging_duplicate(packaging_id: str):
|
||||
try:
|
||||
r = await _tools_request("POST", f"/packaging/{packaging_id}/duplicate")
|
||||
r.raise_for_status()
|
||||
result = r.json()
|
||||
return await _post_nas_export(result)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise _tools_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/api/packaging/{packaging_id}/copy-to-project")
|
||||
async def proxy_packaging_copy(packaging_id: str, body: CopyToProjectBody):
|
||||
try:
|
||||
r = await _tools_request(
|
||||
"POST",
|
||||
f"/packaging/{packaging_id}/copy-to-project",
|
||||
json=body.model_dump(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
result = r.json()
|
||||
return await _post_nas_export(result)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise _tools_error(exc) from exc
|
||||
|
||||
|
||||
@router.delete("/api/packaging/{packaging_id}")
|
||||
async def proxy_packaging_delete(packaging_id: str):
|
||||
try:
|
||||
r = await _tools_request("DELETE", f"/packaging/{packaging_id}")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise _tools_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/api/packaging/{packaging_id}/email")
|
||||
async def proxy_packaging_email(packaging_id: str, body: PackagingEmailBody):
|
||||
base = settings.COCKPIT_PUBLIC_URL if hasattr(settings, "COCKPIT_PUBLIC_URL") else "http://10.4.7.18:8600"
|
||||
links = "\n".join(
|
||||
f"- {fmt.upper()}: {base}/api/packaging/download/{packaging_id}?format={fmt}"
|
||||
for fmt in ("svg", "png", "pdf")
|
||||
)
|
||||
email_body = (body.body or "").strip()
|
||||
if email_body:
|
||||
email_body += "\n\n"
|
||||
email_body += f"Packaging downloads:\n{links}"
|
||||
try:
|
||||
r = await _tools_request(
|
||||
"POST",
|
||||
"/emails/send",
|
||||
json={
|
||||
"to": body.to,
|
||||
"cc": body.cc,
|
||||
"subject": body.subject,
|
||||
"body": email_body,
|
||||
"client_id": body.client_id,
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise _tools_error(exc) from exc
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Projects + UI preferences API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services import projects, ui_preferences, nas_folders
|
||||
|
||||
router = APIRouter(tags=["projects-api"])
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
client_id: Optional[int] = None
|
||||
description: str = ""
|
||||
created_by: str = "ceo"
|
||||
project_type: str = "general"
|
||||
priority: str = "normal"
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
client_id: Optional[int] = None
|
||||
project_type: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
|
||||
|
||||
class AssetCreate(BaseModel):
|
||||
asset_type: str
|
||||
title: str
|
||||
ref_id: Optional[str] = None
|
||||
file_path: Optional[str] = None
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
created_by: str = "ceo"
|
||||
source_agent: Optional[str] = None
|
||||
|
||||
|
||||
class LinkPhotoBody(BaseModel):
|
||||
project_id: int
|
||||
created_by: str = "ceo"
|
||||
|
||||
|
||||
class LinkNasFileBody(BaseModel):
|
||||
title: str
|
||||
file_path: str
|
||||
asset_type: str = "document"
|
||||
|
||||
|
||||
class PreferencesBody(BaseModel):
|
||||
dashboard_layout: Optional[list[str]] = None
|
||||
global_viz_mode: Optional[str] = None
|
||||
viz_modes: Optional[dict[str, str]] = None
|
||||
locale: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
async def projects_page(request: Request):
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent.parent / "templates"))
|
||||
return templates.TemplateResponse("projects.html", {"request": request, "page_title": "Projecten"})
|
||||
|
||||
|
||||
@router.get("/api/projects/types")
|
||||
def api_project_types() -> dict[str, Any]:
|
||||
return {"items": nas_folders.list_types()}
|
||||
|
||||
|
||||
@router.get("/api/projects/stats")
|
||||
def api_project_stats() -> dict[str, Any]:
|
||||
return {"ok": True, "stats": projects.project_stats()}
|
||||
|
||||
|
||||
@router.get("/api/projects")
|
||||
def api_list_projects(client_id: Optional[int] = None, limit: int = 50) -> dict[str, Any]:
|
||||
items = projects.list_projects(client_id=client_id, limit=limit)
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.post("/api/projects")
|
||||
def api_create_project(body: ProjectCreate) -> dict[str, Any]:
|
||||
try:
|
||||
row = projects.create_project(
|
||||
body.name,
|
||||
client_id=body.client_id,
|
||||
description=body.description,
|
||||
created_by=body.created_by,
|
||||
project_type=body.project_type,
|
||||
priority=body.priority,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return {"ok": True, "project": row}
|
||||
|
||||
|
||||
@router.get("/api/projects/{project_id}")
|
||||
def api_get_project(project_id: int) -> dict[str, Any]:
|
||||
row = projects.get_project(project_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return {"project": row}
|
||||
|
||||
|
||||
@router.patch("/api/projects/{project_id}")
|
||||
def api_update_project(project_id: int, body: ProjectUpdate) -> dict[str, Any]:
|
||||
row = projects.update_project(project_id, **body.model_dump(exclude_none=True))
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return {"ok": True, "project": row}
|
||||
|
||||
|
||||
@router.post("/api/projects/{project_id}/ensure-nas")
|
||||
def api_ensure_nas(project_id: int) -> dict[str, Any]:
|
||||
row = projects.get_project(project_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
client_name = row.get("client_name")
|
||||
try:
|
||||
paths = nas_folders.ensure_project_folder(
|
||||
project_id,
|
||||
row["name"],
|
||||
row.get("client_id"),
|
||||
client_name,
|
||||
row.get("project_type") or "general",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return {"ok": True, **paths}
|
||||
|
||||
|
||||
@router.post("/api/projects/{project_id}/assets")
|
||||
def api_add_asset(project_id: int, body: AssetCreate) -> dict[str, Any]:
|
||||
if not projects.get_project(project_id):
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
try:
|
||||
asset = projects.add_asset(
|
||||
project_id,
|
||||
body.asset_type,
|
||||
body.title,
|
||||
ref_id=body.ref_id,
|
||||
file_path=body.file_path,
|
||||
payload=body.payload,
|
||||
created_by=body.created_by,
|
||||
source_agent=body.source_agent,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return {"ok": True, "asset": asset}
|
||||
|
||||
|
||||
@router.post("/api/projects/{project_id}/link-nas-file")
|
||||
def api_link_nas_file(project_id: int, body: LinkNasFileBody) -> dict[str, Any]:
|
||||
if not projects.get_project(project_id):
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
asset = projects.add_asset(
|
||||
project_id,
|
||||
body.asset_type,
|
||||
body.title,
|
||||
file_path=body.file_path,
|
||||
created_by="ceo",
|
||||
)
|
||||
return {"ok": True, "asset": asset}
|
||||
|
||||
|
||||
@router.post("/api/projects/photos/{photo_id}/link")
|
||||
def api_link_photo(photo_id: int, body: LinkPhotoBody) -> dict[str, Any]:
|
||||
try:
|
||||
projects.link_photo_to_project(photo_id, body.project_id, body.created_by)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/api/preferences/ui")
|
||||
def api_get_ui_preferences() -> dict[str, Any]:
|
||||
return ui_preferences.get_preferences("ceo")
|
||||
|
||||
|
||||
@router.put("/api/preferences/ui")
|
||||
def api_save_ui_preferences(body: PreferencesBody) -> dict[str, Any]:
|
||||
allowed = {m["id"] for m in ui_preferences.VIZ_MODES}
|
||||
if body.global_viz_mode and body.global_viz_mode not in allowed:
|
||||
raise HTTPException(status_code=400, detail="Invalid viz mode")
|
||||
if body.locale and body.locale not in ("nl", "en"):
|
||||
raise HTTPException(status_code=400, detail="Invalid locale (nl or en)")
|
||||
prefs = ui_preferences.save_preferences(
|
||||
"ceo",
|
||||
dashboard_layout=body.dashboard_layout,
|
||||
global_viz_mode=body.global_viz_mode,
|
||||
viz_modes=body.viz_modes,
|
||||
locale=body.locale,
|
||||
)
|
||||
return {"ok": True, "preferences": prefs}
|
||||
@@ -25,6 +25,48 @@ class CrmLinkBody(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SupermarketCreateBody(BaseModel):
|
||||
name: str
|
||||
chain: str
|
||||
address: str
|
||||
postcode: str = "0000AA"
|
||||
city: str
|
||||
province: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
manager_name: Optional[str] = None
|
||||
employee_count: Optional[int] = None
|
||||
store_type: Optional[str] = None
|
||||
partnership_status: str = "none"
|
||||
halal_certified: bool = False
|
||||
has_halal_section: bool = False
|
||||
halal_certifier: Optional[str] = None
|
||||
|
||||
|
||||
class SupermarketPatchBody(BaseModel):
|
||||
name: Optional[str] = None
|
||||
chain: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
postcode: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
province: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
manager_name: Optional[str] = None
|
||||
employee_count: Optional[int] = None
|
||||
store_type: Optional[str] = None
|
||||
size_m2: Optional[int] = None
|
||||
partnership_status: Optional[str] = None
|
||||
halal_certified: Optional[bool] = None
|
||||
has_halal_section: Optional[bool] = None
|
||||
halal_certifier: Optional[str] = None
|
||||
halal_certificate_number: Optional[str] = None
|
||||
organic_section: Optional[bool] = None
|
||||
alcohol_section: Optional[bool] = None
|
||||
|
||||
|
||||
class NoteBody(BaseModel):
|
||||
body: str
|
||||
title: Optional[str] = None
|
||||
@@ -88,6 +130,13 @@ async def _tools_delete(path: str) -> Any:
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _tools_patch(path: str, json_body: Optional[dict] = None) -> Any:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
resp = await client.patch(f"{TOOLS}{path}", json=json_body or {})
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
@router.get("/retail")
|
||||
async def retail_page(request: Request):
|
||||
stats = await _tools_get("/retail/stats")
|
||||
@@ -153,6 +202,24 @@ 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/supermarkets")
|
||||
async def api_create_supermarket(body: SupermarketCreateBody):
|
||||
return JSONResponse(await _tools_post("/retail/supermarkets", json_body=body.model_dump()))
|
||||
|
||||
|
||||
@router.patch("/api/retail/supermarkets/{store_id}")
|
||||
async def api_patch_supermarket(store_id: int, body: SupermarketPatchBody):
|
||||
return JSONResponse(await _tools_patch(
|
||||
f"/retail/supermarkets/{store_id}",
|
||||
json_body=body.model_dump(exclude_unset=True),
|
||||
))
|
||||
|
||||
|
||||
@router.delete("/api/retail/supermarkets/{store_id}/link/{client_id}")
|
||||
async def api_unlink_store(store_id: int, client_id: int):
|
||||
return JSONResponse(await _tools_delete(f"/retail/supermarkets/{store_id}/link/{client_id}"))
|
||||
|
||||
|
||||
@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}))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user