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
+141 -1
View File
@@ -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"))}
+2 -2
View File
@@ -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:
+133 -2
View File
@@ -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}
+4 -1
View File
@@ -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
View File
@@ -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
+197
View File
@@ -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}
+67
View File
@@ -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}))