SysOps: deploy-all — 2026-06-09 10:41 UTC
This commit is contained in:
@@ -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}))
|
||||
|
||||
Reference in New Issue
Block a user