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
+144
View File
@@ -0,0 +1,144 @@
"""Daily configuration backup to Gitea — executed after CEO approval."""
from __future__ import annotations
import json
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.db import execute, fetch_all, fetch_one
BACKUP_ROOT = Path(os.getenv("BACKUP_ROOT", "/data/backup-root"))
GITEA_REMOTE = os.getenv(
"GITEA_BACKUP_REMOTE",
"http://sysops:Foodlinkk%23SysOps2026@gitea:3001/aissa/foodlinkk-command-center.git",
)
GITEA_GIT_USER = os.getenv("GITEA_SYSOPS_USER", "sysops")
GITEA_GIT_EMAIL = os.getenv("GITEA_SYSOPS_EMAIL", "sysops@foodlinkk.local")
GITEA_GIT_NAME = os.getenv("GITEA_SYSOPS_NAME", "sysops")
GITEA_BRANCH = os.getenv("GITEA_BACKUP_BRANCH", "main")
BACKUP_PATHS = [
"docker-compose.yml",
".env.example",
"migrations",
"monitoring",
"cockpit/requirements.txt",
"tools-api/requirements.txt",
"deploy-all.sh",
"README.md",
]
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 120) -> dict[str, Any]:
try:
proc = subprocess.run( # noqa: S603
cmd,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "timeout", "cmd": cmd}
except FileNotFoundError as exc:
return {"ok": False, "error": str(exc), "cmd": cmd}
return {
"ok": proc.returncode == 0,
"code": proc.returncode,
"stdout": (proc.stdout or "").strip(),
"stderr": (proc.stderr or "").strip(),
"cmd": cmd,
}
def _table_exists(name: str) -> bool:
row = fetch_one(
"""
SELECT EXISTS(
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
) AS ok
""",
(name,),
)
return bool(row and row.get("ok"))
def _record_backup(
status: str,
message: str,
files_count: int = 0,
commit_ref: str | None = None,
approval_request_id: int | None = None,
payload: dict | None = None,
) -> dict[str, Any] | None:
if not _table_exists("config_backups"):
return None
row = fetch_one(
"""
INSERT INTO config_backups (approval_request_id, commit_ref, files_count, status, message, payload)
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
RETURNING id, created_at
""",
(
approval_request_id,
commit_ref,
files_count,
status,
message,
json.dumps(payload or {}),
),
)
return dict(row) if row else None
def list_backups(limit: int = 20) -> list[dict[str, Any]]:
if not _table_exists("config_backups"):
return []
rows = fetch_all(
"SELECT * FROM config_backups ORDER BY created_at DESC LIMIT %s",
(max(1, min(limit, 100)),),
)
out = []
for r in rows:
item = dict(r)
if hasattr(item.get("created_at"), "isoformat"):
item["created_at"] = item["created_at"].isoformat()
out.append(item)
return out
def prepare_backup_manifest() -> dict[str, Any]:
root = BACKUP_ROOT
files: list[str] = []
if root.exists():
for rel in BACKUP_PATHS:
p = root / rel
if p.is_file():
files.append(rel)
elif p.is_dir():
for fp in p.rglob("*"):
if fp.is_file() and not any(x in str(fp) for x in [".git", "__pycache__", ".pyc"]):
files.append(str(fp.relative_to(root)))
return {
"root": str(root),
"root_exists": root.exists(),
"files": sorted(files)[:500],
"files_count": len(files),
"paths_configured": BACKUP_PATHS,
"generated_at": _iso_now(),
}
def run_gitea_backup(approval_request_id: int | None = None) -> dict[str, Any]:
from app.connectors.gitea_repo_sync import run_gitea_repo_sync
return run_gitea_repo_sync(reason="approved_backup", approval_request_id=approval_request_id)
+248
View File
@@ -0,0 +1,248 @@
"""Volledige codebase sync naar Gitea main — SysOps."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.db import execute, fetch_all, fetch_one
from app.middleware import log_agent_event
REPO_ROOT = Path(os.getenv("BACKUP_ROOT", "/data/backup-root"))
GITEA_REMOTE = os.getenv(
"GITEA_BACKUP_REMOTE",
"http://sysops:Foodlinkk%23SysOps2026@gitea:3001/aissa/foodlinkk-command-center.git",
)
GITEA_GIT_USER = os.getenv("GITEA_SYSOPS_USER", "sysops")
GITEA_GIT_EMAIL = os.getenv("GITEA_SYSOPS_EMAIL", "sysops@foodlinkk.local")
GITEA_GIT_NAME = os.getenv("GITEA_SYSOPS_NAME", "sysops")
GITEA_BRANCH = os.getenv("GITEA_BACKUP_BRANCH", "main")
SKIP_NAMES = {
".git",
"__pycache__",
".env",
"node_modules",
".cursor",
".venv",
"venv",
"*.pyc",
}
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 180) -> dict[str, Any]:
try:
proc = subprocess.run( # noqa: S603
cmd,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "timeout", "cmd": cmd}
except FileNotFoundError as exc:
return {"ok": False, "error": str(exc), "cmd": cmd}
return {
"ok": proc.returncode == 0,
"code": proc.returncode,
"stdout": (proc.stdout or "").strip(),
"stderr": (proc.stderr or "").strip(),
"cmd": cmd,
}
def _table_exists(name: str) -> bool:
row = fetch_one(
"""
SELECT EXISTS(
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
) AS ok
""",
(name,),
)
return bool(row and row.get("ok"))
def _record_activity(
action_type: str,
title: str,
body: str = "",
commit_ref: str | None = None,
files_changed: int = 0,
status: str = "success",
metadata: dict | None = None,
) -> dict[str, Any] | None:
if not _table_exists("sysops_activity"):
return None
row = fetch_one(
"""
INSERT INTO sysops_activity (action_type, title, body, commit_ref, files_changed, status, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id, created_at
""",
(action_type, title, body, commit_ref, files_changed, status, json.dumps(metadata or {})),
)
return dict(row) if row else None
def list_activity(limit: int = 30) -> list[dict[str, Any]]:
if not _table_exists("sysops_activity"):
return []
rows = fetch_all(
"SELECT * FROM sysops_activity ORDER BY created_at DESC LIMIT %s",
(max(1, min(limit, 100)),),
)
out = []
for r in rows:
item = dict(r)
if hasattr(item.get("created_at"), "isoformat"):
item["created_at"] = item["created_at"].isoformat()
out.append(item)
return out
def _should_skip(name: str) -> bool:
if name in SKIP_NAMES:
return True
if name.endswith(".pyc") or name.endswith(".pyo"):
return True
return False
def _copy_tree(src: Path, dest: Path) -> int:
"""Kopieer repo-inhoud naar git worktree; retour aantal bestanden."""
count = 0
if not src.exists():
return 0
for item in src.iterdir():
if _should_skip(item.name):
continue
target = dest / item.name
if item.is_file():
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, target)
count += 1
elif item.is_dir():
if target.exists():
shutil.rmtree(target, ignore_errors=True)
shutil.copytree(
item,
target,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".env", "node_modules", ".git"),
)
count += sum(1 for _ in target.rglob("*") if _.is_file())
return count
def run_gitea_repo_sync(reason: str = "deploy", approval_request_id: int | None = None) -> dict[str, Any]:
"""Push volledige codebase naar Gitea main branch."""
work = Path("/tmp/foodlinkk-gitea-sync")
if work.exists():
shutil.rmtree(work, ignore_errors=True)
work.mkdir(parents=True, exist_ok=True)
if not REPO_ROOT.exists():
msg = f"Repo root niet gevonden: {REPO_ROOT}"
_record_activity("gitea_sync", "Gitea sync mislukt", msg, status="failed")
return {"ok": False, "message": msg}
repo = work / "repo"
clone = _run(["git", "clone", "--depth", "1", "-b", GITEA_BRANCH, GITEA_REMOTE, str(repo)])
if not clone.get("ok"):
repo = work
_run(["git", "init", "-b", GITEA_BRANCH], cwd=repo)
_run(["git", "remote", "add", "origin", GITEA_REMOTE], cwd=repo)
_run(["git", "config", "user.email", GITEA_GIT_EMAIL], cwd=repo)
_run(["git", "config", "user.name", GITEA_GIT_NAME], cwd=repo)
copied = _copy_tree(REPO_ROOT, repo)
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
commit_msg = f"SysOps: {reason}{stamp}"
_run(["git", "add", "-A"], cwd=repo)
commit = _run(["git", "commit", "-m", commit_msg], cwd=repo)
nothing = "nothing to commit" in (commit.get("stdout", "") + commit.get("stderr", ""))
if not commit.get("ok") and not nothing:
msg = "Git commit mislukt: " + (commit.get("stderr") or "")
_record_activity("gitea_sync", "Gitea commit mislukt", msg, files_changed=copied, status="failed")
return {"ok": False, "message": msg, "copied": copied}
commit_ref = None
if not nothing:
push = _run(["git", "push", "-u", "origin", GITEA_BRANCH], cwd=repo, timeout=300)
if not push.get("ok"):
msg = "Git push mislukt: " + (push.get("stderr") or "")
_record_activity("gitea_sync", "Gitea push mislukt", msg, files_changed=copied, status="failed")
return {"ok": False, "message": msg, "copied": copied}
rev = _run(["git", "rev-parse", "--short", "HEAD"], cwd=repo)
commit_ref = rev.get("stdout") or None
summary = (
f"Volledige codebase gesynchroniseerd naar Gitea ({reason}). "
f"{copied} bestanden · commit {commit_ref or 'geen wijzigingen'}"
)
activity = _record_activity(
"gitea_sync",
f"Gitea push: {reason}",
summary,
commit_ref=commit_ref,
files_changed=copied,
status="success" if (commit_ref or nothing) else "skipped",
metadata={"reason": reason, "approval_request_id": approval_request_id, "nothing_to_commit": nothing},
)
try:
log_agent_event(
agent_name="sysops",
agent_type="sysops",
event_type="gitea_sync",
title=f"Gitea sync: {reason}" + (f" ({commit_ref})" if commit_ref else " — geen wijzigingen"),
body=summary,
metadata={
"commit_ref": commit_ref,
"files_changed": copied,
"reason": reason,
"for_herman": True,
},
channel="sysops",
)
except Exception:
pass
if _table_exists("config_backups"):
fetch_one(
"""
INSERT INTO config_backups (approval_request_id, commit_ref, files_count, status, message, payload)
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
RETURNING id
""",
(
approval_request_id,
commit_ref,
copied,
"success" if commit_ref else "unchanged",
summary,
json.dumps({"reason": reason, "full_repo": True}),
),
)
return {
"ok": True,
"message": summary,
"commit_ref": commit_ref,
"files_count": copied,
"nothing_to_commit": nothing,
"activity_id": (activity or {}).get("id"),
}
+179
View File
@@ -0,0 +1,179 @@
"""Daily maintenance checks for IT Ops tab."""
from __future__ import annotations
import json
import subprocess
from datetime import datetime, timezone
from typing import Any
from app.db import execute, fetch_all, fetch_one
from app.connectors.proxmox import PROXMOX_HOST, VM_106_IP, _port_health, _run_ssh, check_docker_services
SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _table_exists(name: str) -> bool:
row = fetch_one(
"""
SELECT EXISTS(
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
) AS ok
""",
(name,),
)
return bool(row and row.get("ok"))
def list_notes(limit: int = 20, unresolved_only: bool = True) -> list[dict[str, Any]]:
if not _table_exists("ops_maintenance_notes"):
return []
clauses = ["resolved = false"] if unresolved_only else []
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
rows = fetch_all(
f"SELECT * FROM ops_maintenance_notes{where} ORDER BY created_at DESC LIMIT %s",
(max(1, min(limit, 100)),),
)
out = []
for r in rows:
item = dict(r)
if hasattr(item.get("created_at"), "isoformat"):
item["created_at"] = item["created_at"].isoformat()
out.append(item)
return out
def add_note(title: str, body: str = "", severity: str = "info", source: str = "sysops") -> dict[str, Any] | None:
if not _table_exists("ops_maintenance_notes"):
return None
row = fetch_one(
"""
INSERT INTO ops_maintenance_notes (severity, title, body, source)
VALUES (%s, %s, %s, %s)
RETURNING *
""",
(severity, title[:255], body, source),
)
if not row:
return None
item = dict(row)
if hasattr(item.get("created_at"), "isoformat"):
item["created_at"] = item["created_at"].isoformat()
return item
def run_maintenance_scan() -> dict[str, Any]:
findings: list[dict[str, Any]] = []
proxmox_up = _port_health(PROXMOX_HOST, 8006)
if not proxmox_up:
findings.append({
"severity": "critical",
"title": "Proxmox Web UI niet bereikbaar",
"body": f"Poort 8006 op {PROXMOX_HOST} reageert niet. Controleer Dell R340 / Proxmox VE.",
})
docker = check_docker_services()
if not docker.get("ok"):
findings.append({
"severity": "warning",
"title": "Docker status onbekend",
"body": docker.get("error") or "Kon docker ps niet uitvoeren op VM106.",
})
else:
containers = docker.get("containers") or []
unhealthy = [
c for c in containers
if "unhealthy" in str(c.get("Status") or c.get("State") or "").lower()
]
if unhealthy:
names = ", ".join(str(c.get("Names") or c.get("Name") or "?")[:40] for c in unhealthy[:5])
findings.append({
"severity": "warning",
"title": f"{len(unhealthy)} container(s) unhealthy",
"body": names,
})
apt = _run_ssh(VM_106_IP, "apt list --upgradable 2>/dev/null | grep -v Listing | wc -l")
if apt.get("ok"):
try:
count = int((apt.get("stdout") or "0").strip())
except ValueError:
count = 0
if count > 0:
findings.append({
"severity": "info" if count < 10 else "warning",
"title": f"{count} package updates beschikbaar op VM106",
"body": "Voer maintenance uit wanneer gepland: apt update && apt upgrade",
})
disk = _run_ssh(VM_106_IP, "df -h / | tail -1 | awk '{print $5}'")
if disk.get("ok"):
pct = (disk.get("stdout") or "").replace("%", "").strip()
try:
used = int(pct)
if used >= 90:
findings.append({
"severity": "critical",
"title": f"Schijf VM106 {used}% vol",
"body": "Ruimte vrijmaken op root volume.",
})
elif used >= 80:
findings.append({
"severity": "warning",
"title": f"Schijf VM106 {used}% vol",
"body": "Plan opschoning binnenkort.",
})
except ValueError:
pass
saved: list[dict[str, Any]] = []
for f in findings:
note = add_note(f["title"], f.get("body", ""), f.get("severity", "info"))
if note:
saved.append(note)
if not findings:
note = add_note(
"Geen openstaande maintenance — alles OK",
f"Scan {_iso_now()}: Proxmox, Docker en schijf binnen normen.",
"info",
)
if note:
saved.append(note)
saved.sort(key=lambda n: SEVERITY_ORDER.get(str(n.get("severity")), 9))
try:
from app.connectors.gitea_repo_sync import _record_activity
from app.middleware import log_agent_event
summary = f"{len(findings)} bevinding(en) — scan {_iso_now()[:19]}"
_record_activity(
"maintenance_scan",
f"Maintenance scan: {len(findings)} bevinding(en)",
summary + "\n" + "\n".join(f"- {f['title']}" for f in findings[:8]),
metadata={"findings_count": len(findings), "for_herman": True},
)
log_agent_event(
agent_name="sysops",
agent_type="sysops",
event_type="maintenance_scan",
title=f"Maintenance scan voltooid — {len(findings)} bevinding(en)",
body=summary,
metadata={"findings": [f.get("title") for f in findings[:10]], "for_herman": True},
channel="sysops",
)
except Exception:
pass
return {
"ok": True,
"scanned_at": _iso_now(),
"findings_count": len(findings),
"notes": saved,
}
@@ -0,0 +1,83 @@
"""Register agent outputs as project assets (shared DB helpers)."""
from __future__ import annotations
import json
from typing import Any, Optional
from app.db import execute, fetch_one
def get_or_create_agent_project(agent_key: str, client_id: int | None = 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"])
title = f"Agent · {key}"
created = fetch_one(
"""
INSERT INTO cockpit_projects (name, client_id, description, created_by, metadata, updated_at)
VALUES (%s, %s, %s, %s, %s::jsonb, NOW())
RETURNING id
""",
(
title,
client_id,
f"Automatisch project voor {key} output",
key,
json.dumps({"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: int | None = None,
source_agent: str | None = None,
created_by: str | None = None,
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}
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 id, project_id, asset_type, ref_id, title
""",
(
pid,
asset_type,
ref_id,
title[:255],
file_path,
json.dumps(payload or {}),
created_by or agent,
agent,
),
)
execute("UPDATE cockpit_projects SET updated_at = NOW() WHERE id = %s", (pid,))
return dict(row) if row else None
+157 -178
View File
@@ -1,4 +1,4 @@
"""Proxmox infrastructure monitoring connector for Foodlinkk IT Ops."""
"""Proxmox infrastructure monitoring — Dell R340 · Proxmox VE · VM106 services."""
from __future__ import annotations
import json
@@ -15,20 +15,30 @@ from urllib.request import Request, urlopen
from app.db import execute, fetch_all, fetch_one
PROXMOX_HOST = "10.4.7.14"
PROXMOX_HOST = os.getenv("PROXMOX_HOST", "10.4.7.14")
PROXMOX_API_URL = f"https://{PROXMOX_HOST}:8006/api2/json"
SSH_USER = "aissa"
SSH_PASSWORD = "Foodlinkk#2026"
SSH_USER = os.getenv("PROXMOX_SSH_USER", "aissa")
SSH_PASSWORD = os.getenv("PROXMOX_SSH_PASS", "Foodlinkk#2026")
VM_105_IP = "10.4.7.19"
VM_106_IP = "10.4.7.18"
VM_106_IP = os.getenv("VM106_IP", "10.4.7.18")
VM_106_ID = 106
VM_106_NAME = "dockervm"
SERVICE_LAYOUT: list[dict[str, Any]] = [
{"id": "svc-cockpit", "label": "cockpit:8600", "host": VM_106_IP, "parent": "vm106-command", "port": 8600},
{"id": "svc-tools-api", "label": "tools-api:8700", "host": VM_106_IP, "parent": "vm106-command", "port": 8700},
{"id": "svc-email-agent", "label": "email-agent:8801", "host": VM_106_IP, "parent": "vm106-command", "port": 8801},
{"id": "svc-gitea", "label": "gitea:3001", "host": VM_105_IP, "parent": "vm105-hermes", "port": 3001},
{"id": "svc-ollama", "label": "ollama:11434", "host": VM_105_IP, "parent": "vm105-hermes", "port": 11434},
HARDWARE_LABEL = "Dell PowerEdge R340"
SERVICE_CATALOG: list[dict[str, Any]] = [
{"id": "svc-cockpit", "label": "Cockpit", "port": 8600, "url": f"http://{VM_106_IP}:8600", "role": "Command Center UI"},
{"id": "svc-tools-api", "label": "Tools API", "port": 8700, "url": f"http://{VM_106_IP}:8700/docs", "role": "Connectors & API"},
{"id": "svc-gitea", "label": "Gitea", "port": 3001, "url": f"http://{VM_106_IP}:3001", "role": "Git · config backups"},
{"id": "svc-email-agent", "label": "Email Agent", "port": 8801, "url": f"http://{VM_106_IP}:8801", "role": "IMAP/SMTP sync"},
{"id": "svc-grafana", "label": "Grafana", "port": 3002, "url": f"http://{VM_106_IP}:3002", "role": "Metrics dashboards"},
{"id": "svc-prometheus", "label": "Prometheus", "port": 9090, "url": f"http://{VM_106_IP}:9090", "role": "Metrics scrape"},
{"id": "svc-browser-agent", "label": "Browser Agent", "port": 7790, "url": f"http://{VM_106_IP}:7790", "role": "Web automation"},
]
LAYER_AGENTS: list[dict[str, Any]] = [
{"id": "mon-sysops", "label": "SysOps", "agent_key": "sysops", "role": "Backup · Proxmox · Docker · maintenance"},
{"id": "mon-research", "label": "Research", "agent_key": "research", "role": "Markt intel (approval gate)"},
]
@@ -38,26 +48,16 @@ def _iso_now() -> str:
def _run_ssh(host: str, command: str, timeout: int = 12) -> dict[str, Any]:
ssh_cmd = [
"sshpass",
"-p",
SSH_PASSWORD,
"ssh",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"ConnectTimeout=7",
"sshpass", "-p", SSH_PASSWORD, "ssh",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=7",
f"{SSH_USER}@{host}",
command,
]
try:
proc = subprocess.run( # noqa: S603
ssh_cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
ssh_cmd, capture_output=True, text=True, timeout=timeout, check=False,
)
except FileNotFoundError as exc:
return {"ok": False, "error": f"ssh tooling missing: {exc}"}
@@ -77,15 +77,12 @@ def _http_get_json(url: str, headers: dict[str, str] | None = None, timeout: int
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with urlopen(req, timeout=timeout, context=ctx) as resp: # noqa: S310
payload = resp.read().decode("utf-8")
return json.loads(payload)
return json.loads(resp.read().decode("utf-8"))
def _build_token_header(token_value: str) -> str:
val = token_value.strip()
if val.startswith("PVEAPIToken="):
return val
return f"PVEAPIToken={val}"
return val if val.startswith("PVEAPIToken=") else f"PVEAPIToken={val}"
def _create_api_token_via_ssh() -> str | None:
@@ -101,68 +98,62 @@ def _create_api_token_via_ssh() -> str | None:
parsed = json.loads(result.get("stdout") or "{}")
except json.JSONDecodeError:
return None
tokenid = parsed.get("full-tokenid")
secret = parsed.get("value")
if tokenid and secret:
return f"PVEAPIToken={tokenid}={secret}"
return None
tokenid, secret = parsed.get("full-tokenid"), parsed.get("value")
return f"PVEAPIToken={tokenid}={secret}" if tokenid and secret else None
def _fetch_nodes_via_api() -> tuple[list[dict[str, Any]], str, str | None]:
def _fetch_proxmox_state() -> tuple[str, float, float, str, str, str | None]:
token = os.getenv("PROXMOX_TOKEN")
tried = []
api_source, api_error = "none", None
nodes: list[dict[str, Any]] = []
if token:
tried.append("env-token")
try:
data = _http_get_json(
f"{PROXMOX_API_URL}/nodes",
headers={"Authorization": _build_token_header(token)},
)
return data.get("data") or [], "api-token-env", None
data = _http_get_json(f"{PROXMOX_API_URL}/nodes", headers={"Authorization": _build_token_header(token)})
nodes = data.get("data") or []
api_source = "api-token-env"
except Exception as exc: # noqa: BLE001
tried.append(f"env-failed:{exc}")
created = _create_api_token_via_ssh()
if created:
tried.append("ssh-created-token")
try:
data = _http_get_json(
f"{PROXMOX_API_URL}/nodes",
headers={"Authorization": created},
)
return data.get("data") or [], "api-token-ssh", None
except Exception as exc: # noqa: BLE001
tried.append(f"ssh-token-failed:{exc}")
return [], "none", ", ".join(tried) if tried else "no-token"
api_error = str(exc)
if not nodes:
created = _create_api_token_via_ssh()
if created:
try:
data = _http_get_json(f"{PROXMOX_API_URL}/nodes", headers={"Authorization": created})
nodes = data.get("data") or []
api_source = "api-token-ssh"
except Exception as exc: # noqa: BLE001
api_error = str(exc)
def _fetch_nodes_via_ssh() -> tuple[list[dict[str, Any]], str, str | None]:
pvesh = _run_ssh(PROXMOX_HOST, "pvesh get /nodes --output-format json")
if pvesh.get("ok"):
try:
return json.loads(pvesh["stdout"] or "[]"), "ssh-pvesh", None
except json.JSONDecodeError:
pass
qm = _run_ssh(PROXMOX_HOST, "qm list")
rows: list[dict[str, Any]] = []
if not nodes:
pvesh = _run_ssh(PROXMOX_HOST, "pvesh get /nodes --output-format json")
if pvesh.get("ok"):
try:
nodes = json.loads(pvesh["stdout"] or "[]")
api_source = "ssh-pvesh"
except json.JSONDecodeError:
api_error = pvesh.get("stderr") or "pvesh parse error"
api_node = next((n for n in nodes if isinstance(n, dict) and (n.get("node") or "").strip()), None)
cpu = float(api_node.get("cpu", 0)) if api_node else 0.0
mem = float(api_node.get("mem", 0)) if api_node else 0.0
status = str(api_node.get("status") or "unknown") if api_node else "unknown"
vm_status = "unknown"
qm = _run_ssh(PROXMOX_HOST, f"qm status {VM_106_ID} 2>/dev/null || qm list | grep '^{VM_106_ID} '")
if qm.get("ok") and qm.get("stdout"):
lines = (qm["stdout"] or "").splitlines()
for line in lines[1:]:
parts = line.split()
if not parts:
continue
vmid = parts[0]
rows.append(
{
"node": "pve",
"type": "qemu",
"id": f"qemu/{vmid}",
"vmid": int(vmid) if vmid.isdigit() else vmid,
"status": parts[2] if len(parts) > 2 else "unknown",
}
)
return rows, "ssh-qm-list", None
err = pvesh.get("stderr") or qm.get("stderr") or "ssh lookup failed"
return [], "none", err
line = qm["stdout"].splitlines()[0].lower()
if "running" in line:
vm_status = "running"
elif "stopped" in line:
vm_status = "stopped"
else:
vm_status = "online" if _port_health(VM_106_IP, 22) else "offline"
if status == "unknown" and _port_health(PROXMOX_HOST, 8006):
status = "online"
return status, cpu, mem, vm_status, api_source, api_error
def _port_health(host: str, port: int, timeout: float = 1.5) -> bool:
@@ -176,13 +167,10 @@ def _port_health(host: str, port: int, timeout: float = 1.5) -> bool:
def check_docker_services() -> dict[str, Any]:
result = _run_ssh(VM_106_IP, "docker ps --format json")
method = "docker-ps-json"
if not result.get("ok"):
result = _run_ssh(VM_106_IP, "docker ps --format '{{json .}}'")
method = "docker-ps-template-json"
if not result.get("ok"):
return {"ok": False, "source": method, "error": result.get("stderr") or "docker check failed", "containers": []}
return {"ok": False, "error": result.get("stderr") or "docker check failed", "containers": []}
containers: list[dict[str, Any]] = []
for line in (result.get("stdout") or "").splitlines():
line = line.strip()
@@ -193,102 +181,99 @@ def check_docker_services() -> dict[str, Any]:
containers.append(parsed if isinstance(parsed, dict) else {"raw": parsed})
except json.JSONDecodeError:
containers.append({"raw": line})
return {"ok": True, "source": method, "containers": containers}
return {"ok": True, "containers": containers}
def _online_services() -> list[dict[str, Any]]:
docker_state = check_docker_services()
docker_names = {
str(c.get("Names") or c.get("Name") or "").lower(): c
for c in docker_state.get("containers", [])
}
online: list[dict[str, Any]] = []
for svc in SERVICE_CATALOG:
up = _port_health(VM_106_IP, int(svc["port"]))
if not up:
continue
hint = "running"
for name, details in docker_names.items():
slug = svc["id"].replace("svc-", "").replace("-", "")
if slug in name.replace("-", ""):
hint = str(details.get("State") or details.get("Status") or "running")
break
online.append({
"id": svc["id"],
"label": f"{svc['label']}:{svc['port']}",
"type": "service",
"status": "online",
"host": VM_106_IP,
"port": svc["port"],
"url": svc["url"],
"role": svc["role"],
"hint": hint,
"agent": "sysops",
"children": [],
})
return online
def get_topology() -> dict[str, Any]:
api_nodes, api_source, api_error = _fetch_nodes_via_api()
ssh_nodes: list[dict[str, Any]] = []
ssh_source = "none"
ssh_error: str | None = None
if not api_nodes:
ssh_nodes, ssh_source, ssh_error = _fetch_nodes_via_ssh()
api_node = next((n for n in api_nodes if (n.get("node") or "").strip()), None) if api_nodes else None
host_cpu = float(api_node.get("cpu", 0)) if api_node else 0.0
host_mem = float(api_node.get("mem", 0)) if api_node else 0.0
host_status = api_node.get("status") if api_node else "unknown"
if host_status == "unknown" and ssh_nodes:
host_status = "online"
vm_states: dict[str, str] = {"105": "unknown", "106": "unknown"}
source_rows = api_nodes or ssh_nodes
for row in source_rows:
vmid = str(row.get("vmid") or "").strip()
if vmid in vm_states:
vm_states[vmid] = str(row.get("status") or "unknown")
docker_state = check_docker_services()
docker_names = {
str(c.get("Names") or c.get("Names.0") or c.get("Name") or "").lower(): c for c in docker_state.get("containers", [])
}
vm105_children: list[dict[str, Any]] = []
vm106_children: list[dict[str, Any]] = []
for svc in SERVICE_LAYOUT:
up = _port_health(str(svc["host"]), int(svc["port"]))
hinted = "unknown"
for name, details in docker_names.items():
if svc["label"].split(":")[0].replace("-", "") in name.replace("-", ""):
hinted = str(details.get("State") or details.get("Status") or "running")
break
item = {
"id": svc["id"],
"label": svc["label"],
"type": "service",
"status": "online" if up else "offline",
"cpu": None,
"mem": None,
"host": svc["host"],
"hint": hinted,
"children": [],
}
if svc["parent"] == "vm105-hermes":
vm105_children.append(item)
else:
vm106_children.append(item)
host_status, host_cpu, host_mem, vm_status, api_source, api_error = _fetch_proxmox_state()
services = _online_services()
proxmox_reachable = _port_health(PROXMOX_HOST, 8006)
topology_nodes = [
{
"id": "proxmox-host",
"label": f"proxmox-host ({PROXMOX_HOST})",
"type": "proxmox",
"status": host_status,
"cpu": host_cpu,
"mem": host_mem,
"id": "hardware-r340",
"label": HARDWARE_LABEL,
"type": "hardware",
"status": "online" if proxmox_reachable or _port_health(VM_106_IP, 8600) else "degraded",
"role": "Bare metal host",
"children": [
{
"id": "vm105-hermes",
"label": f"vm105-hermes ({VM_105_IP})",
"type": "vm",
"status": vm_states["105"],
"cpu": None,
"mem": None,
"children": vm105_children,
},
{
"id": "vm106-command",
"label": f"vm106-command ({VM_106_IP})",
"type": "vm",
"status": vm_states["106"],
"cpu": None,
"mem": None,
"children": vm106_children,
},
"id": "proxmox-host",
"label": f"Proxmox VE · {PROXMOX_HOST}",
"type": "proxmox",
"status": host_status if proxmox_reachable else ("online" if _port_health(PROXMOX_HOST, 22) else "offline"),
"cpu": host_cpu,
"mem": host_mem,
"role": "Hypervisor · node pve",
"url": f"https://{PROXMOX_HOST}:8006",
"children": [
{
"id": "vm106-command",
"label": f"VM {VM_106_ID} · {VM_106_NAME} · {VM_106_IP}",
"type": "vm",
"status": vm_status if vm_status != "unknown" else ("online" if services else "offline"),
"vmid": VM_106_ID,
"role": "Foodlinkk Command Center",
"children": services,
}
],
}
],
}
]
return {
"generated_at": _iso_now(),
"nodes": topology_nodes,
"layer_agents": LAYER_AGENTS,
"layers": [
{"id": "L0", "label": "Hardware", "y": 55},
{"id": "L1", "label": "Hypervisor", "y": 175},
{"id": "L2", "label": f"VM {VM_106_ID}", "y": 295},
{"id": "L3", "label": "Online services", "y": 415},
{"id": "L4", "label": "IT Agents", "y": 555},
],
"meta": {
"hardware": HARDWARE_LABEL,
"proxmox_host": PROXMOX_HOST,
"vm106_ip": VM_106_IP,
"vm106_id": VM_106_ID,
"services_online": len(services),
"api_source": api_source,
"api_error": api_error,
"ssh_source": ssh_source,
"ssh_error": ssh_error,
"docker_source": docker_state.get("source"),
"docker_ok": docker_state.get("ok", False),
},
}
@@ -322,10 +307,8 @@ def _table_exists(table_name: str) -> bool:
row = fetch_one(
"""
SELECT EXISTS(
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = %s
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
) AS ok
""",
(table_name,),
@@ -340,10 +323,8 @@ def poll_and_snapshot() -> dict[str, Any]:
cols = fetch_all(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'infra_snapshots'
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'infra_snapshots'
ORDER BY ordinal_position
"""
)
@@ -354,7 +335,6 @@ def poll_and_snapshot() -> dict[str, Any]:
"summary": {k: v for k, v in status.items() if k != "topology"},
"generated_at": status.get("generated_at"),
}
value_map: dict[str, Any] = {}
if "source" in colset:
value_map["source"] = "proxmox"
@@ -375,7 +355,6 @@ def poll_and_snapshot() -> dict[str, Any]:
return {"ok": False, "saved": False, "reason": "infra_snapshots has no compatible columns", "status": status}
columns = list(value_map.keys())
placeholders = ", ".join(["%s"] * len(columns))
sql = f"INSERT INTO infra_snapshots ({', '.join(columns)}) VALUES ({placeholders})"
sql = f"INSERT INTO infra_snapshots ({', '.join(columns)}) VALUES ({', '.join([ '%s' ] * len(columns))})"
execute(sql, tuple(value_map[c] for c in columns))
return {"ok": True, "saved": True, "columns": columns, "status": status}
+51
View File
@@ -1,12 +1,24 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.connectors.config_backup import list_backups, prepare_backup_manifest
from app.connectors.gitea_repo_sync import list_activity, run_gitea_repo_sync
from app.connectors.ops_maintenance import list_notes, run_maintenance_scan
from app.connectors.proxmox import get_status_summary, get_topology, poll_and_snapshot
router = APIRouter(prefix="/ops", tags=["ops"])
class BackupRunBody(BaseModel):
approval_request_id: int | None = None
class GiteaSyncBody(BaseModel):
reason: str = Field(default="manual", max_length=120)
@router.get("/status")
def ops_status() -> dict:
try:
@@ -29,3 +41,42 @@ def ops_refresh() -> dict:
return poll_and_snapshot()
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"ops refresh failed: {exc}") from exc
@router.get("/backups")
def ops_backups(limit: int = 10) -> dict:
return {"items": list_backups(limit), "manifest": prepare_backup_manifest()}
@router.post("/backup/run")
def ops_backup_run(body: BackupRunBody) -> dict:
try:
return run_gitea_repo_sync(reason="approved_backup", approval_request_id=body.approval_request_id)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("/gitea/sync")
def ops_gitea_sync(body: GiteaSyncBody) -> dict:
try:
return run_gitea_repo_sync(reason=body.reason)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.get("/sysops-activity")
def ops_sysops_activity(limit: int = 30) -> dict:
return {"items": list_activity(limit), "count": len(list_activity(limit))}
@router.get("/maintenance")
def ops_maintenance(limit: int = 15) -> dict:
return {"items": list_notes(limit, unresolved_only=True)}
@router.post("/maintenance/scan")
def ops_maintenance_scan() -> dict:
try:
return run_maintenance_scan()
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=str(exc)) from exc
+331 -132
View File
@@ -1,4 +1,4 @@
"""SVG packaging generator for Foodlinkk."""
"""SVG packaging generator for Foodlinkk — rich design options."""
from __future__ import annotations
import base64
@@ -9,8 +9,7 @@ import svgwrite
from barcode import Code128
from barcode.writer import SVGWriter
MM_TO_PX = 3.7795275591 # 96 DPI conversion
MM_TO_PX = 3.7795275591
DEFAULT_BARCODE_VALUE = "8710000000012"
FOODLINKK_BRAND = {
@@ -18,11 +17,24 @@ FOODLINKK_BRAND = {
"panel": "#101a2d",
"primary": "#00e5ff",
"secondary": "#ffd700",
"accent": "#b8ff3c",
"text": "#e2e8f0",
"muted": "#94a3b8",
"cut_line": "#ef4444",
"fold_line": "#60a5fa",
"bleed": "#f97316",
"halal": "#22c55e",
}
PACKAGING_TYPES = (
"folding_box",
"wrap",
"round_label",
"sleeve",
"pouch",
"tray",
)
def _mm(mm: float) -> float:
return round(float(mm) * MM_TO_PX, 2)
@@ -44,32 +56,227 @@ def _barcode_data_uri(value: str) -> str:
return f"data:image/svg+xml;base64,{encoded}"
def _qr_placeholder_svg(size: int = 120) -> str:
"""Simple QR-style grid when qrcode lib unavailable."""
cell = max(size // 10, 8)
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}">',
f'<rect width="{size}" height="{size}" fill="#fff"/>',
]
for y in range(0, size, cell):
for x in range(0, size, cell):
if (x + y) % (cell * 2) == 0 or x < cell * 3 and y < cell * 3 or x > size - cell * 4 and y < cell * 3:
parts.append(f'<rect x="{x}" y="{y}" width="{cell}" height="{cell}" fill="#111"/>')
parts.append("</svg>")
raw = "".join(parts).encode("utf-8")
return f"data:image/svg+xml;base64,{base64.b64encode(raw).decode('ascii')}"
def _spec_text(spec: dict[str, Any]) -> dict[str, str]:
text = spec.get("text") or {}
if not isinstance(text, dict):
text = {}
brand = {**FOODLINKK_BRAND, **(spec.get("brand") or {})}
return {
"product_name": str(text.get("product_name") or brand.get("product_name") or "FOODLINKK"),
"tagline": str(text.get("tagline") or brand.get("tagline") or "Premium food solutions"),
"subtitle": str(text.get("subtitle") or text.get("weight") or ""),
"ingredients": str(text.get("ingredients") or ""),
"best_before": str(text.get("best_before") or "Ten minste houdbaar tot: zie verpakking"),
"origin": str(text.get("origin") or "Geproduceerd in NL"),
}
def _nutrition_rows(spec: dict[str, Any]) -> list[tuple[str, str]]:
text = spec.get("text") or {}
rows = text.get("nutrition") if isinstance(text, dict) else None
if isinstance(rows, list) and rows:
out: list[tuple[str, str]] = []
for row in rows[:12]:
if isinstance(row, dict):
out.append((str(row.get("k") or row.get("label") or ""), str(row.get("v") or row.get("value") or "")))
return out
return [
("Energie", "450 kJ / 107 kcal"),
("Vetten", "4.2 g"),
("waarvan verzadigd", "1.1 g"),
("Koolhydraten", "12 g"),
("waarvan suikers", "2.8 g"),
("Eiwitten", "6.5 g"),
("Zout", "0.85 g"),
]
def _draw_bleed(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, brand: dict[str, str], bleed_mm: float) -> None:
if bleed_mm <= 0:
return
b = _mm(bleed_mm)
dwg.add(
dwg.rect(
insert=(x - b, y - b),
size=(w + 2 * b, h + 2 * b),
fill="none",
stroke=brand["bleed"],
stroke_dasharray="6,4",
stroke_width=1.0,
stroke_opacity=0.7,
)
)
def _draw_logo_block(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, brand: dict[str, str], txt: dict[str, str]) -> None:
dwg.add(
dwg.rect(insert=(x, y), size=(w, h), rx=10, ry=10, fill=brand["panel"], stroke=brand["secondary"], stroke_width=2)
)
dwg.add(
dwg.text(
txt["product_name"][:42],
insert=(x + 14, y + h * 0.42),
fill=brand["text"],
font_size=min(22, max(12, w * 0.045)),
font_family="Arial, Helvetica, sans-serif",
font_weight="bold",
)
)
if txt["tagline"]:
dwg.add(
dwg.text(
txt["tagline"][:60],
insert=(x + 14, y + h * 0.62),
fill=brand["primary"],
font_size=min(14, max(9, w * 0.028)),
font_family="Arial, Helvetica, sans-serif",
)
)
if txt["subtitle"]:
dwg.add(
dwg.text(
txt["subtitle"][:24],
insert=(x + 14, y + h * 0.82),
fill=brand["muted"],
font_size=11,
font_family="Arial, Helvetica, sans-serif",
)
)
def _draw_nutrition_panel(
dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, brand: dict[str, str], rows: list[tuple[str, str]]
) -> None:
dwg.add(dwg.rect(insert=(x, y), size=(w, h), fill="#ffffff", stroke=brand["text"], stroke_width=1.2, rx=4))
dwg.add(
dwg.text(
"Voedingswaarden per 100g",
insert=(x + 8, y + 16),
fill="#111827",
font_size=11,
font_weight="bold",
font_family="Arial, sans-serif",
)
)
line_y = y + 26
for label, value in rows:
dwg.add(dwg.text(label[:28], insert=(x + 8, line_y), fill="#374151", font_size=9, font_family="Arial, sans-serif"))
dwg.add(dwg.text(value[:16], insert=(x + w - 8, line_y), fill="#111827", font_size=9, font_family="Arial, sans-serif", text_anchor="end"))
line_y += 13
if line_y > y + h - 6:
break
def _draw_ingredients(dwg: svgwrite.Drawing, x: float, y: float, w: float, text: str, brand: dict[str, str]) -> None:
dwg.add(
dwg.text(
"Ingrediënten:",
insert=(x, y),
fill=brand["text"],
font_size=10,
font_weight="bold",
font_family="Arial, sans-serif",
)
)
chunk = text[:220] or "Ingrediënten volgens recept — vul aan in Packaging Studio."
words, line, line_y, line_h = chunk.split(), "", y + 14, 12
for word in words:
test = (line + " " + word).strip()
if len(test) > 42:
dwg.add(dwg.text(line, insert=(x, line_y), fill=brand["muted"], font_size=9, font_family="Arial, sans-serif"))
line, line_y = word, line_y + line_h
else:
line = test
if line:
dwg.add(dwg.text(line, insert=(x, line_y), fill=brand["muted"], font_size=9, font_family="Arial, sans-serif"))
def _draw_halal_badge(dwg: svgwrite.Drawing, x: float, y: float, brand: dict[str, str]) -> None:
r = 28
dwg.add(dwg.circle(center=(x + r, y + r), r=r, fill=brand["halal"], stroke="#ffffff", stroke_width=2))
dwg.add(
dwg.text(
"HALAL",
insert=(x + r, y + r + 5),
fill="#052e16",
font_size=11,
font_weight="bold",
font_family="Arial, sans-serif",
text_anchor="middle",
)
)
def _draw_window(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float) -> None:
dwg.add(dwg.rect(insert=(x, y), size=(w, h), fill="#bae6fd", fill_opacity=0.35, stroke="#38bdf8", stroke_width=1.5, rx=6))
dwg.add(dwg.text("WINDOW", insert=(x + w / 2, y + h / 2 + 4), fill="#0c4a6e", font_size=10, text_anchor="middle", font_family="Arial, sans-serif"))
def _draw_dimensions(dwg: svgwrite.Drawing, x: float, y: float, w: float, h: float, spec: dict[str, Any], brand: dict[str, str]) -> None:
label = f"{spec.get('width_mm')} × {spec.get('height_mm')} × {spec.get('depth_mm')} mm"
dwg.add(
dwg.text(
label,
insert=(x + w / 2, y + h + 18),
fill=brand["muted"],
font_size=10,
text_anchor="middle",
font_family="Arial, sans-serif",
)
)
def _canvas_size(ptype: str, width_mm: float, height_mm: float, depth_mm: float) -> tuple[float, float]:
if ptype == "folding_box":
return _mm((width_mm * 2) + (depth_mm * 2) + 20), _mm(height_mm + depth_mm + 20)
if ptype == "wrap":
return _mm(width_mm + 20), _mm(height_mm + 20)
if ptype == "round_label":
d = max(min(width_mm, height_mm), 20)
return _mm(d + 20), _mm(d + 20)
if ptype == "sleeve":
return _mm((width_mm * 2) + depth_mm + 20), _mm(height_mm + 20)
if ptype == "pouch":
return _mm(width_mm + 20), _mm(height_mm + depth_mm + 20)
if ptype == "tray":
return _mm(width_mm + 20), _mm(height_mm + depth_mm + 20)
raise ValueError(f"Unsupported packaging type: {ptype}")
def generate_packaging(spec: dict[str, Any]) -> str:
"""Create an SVG packaging design based on a simple spec."""
"""Create an SVG packaging design from a rich spec."""
ptype = (spec.get("type") or "folding_box").strip().lower()
if ptype not in PACKAGING_TYPES:
raise ValueError(f"Unsupported packaging type: {ptype}")
width_mm = float(spec.get("width_mm", 120))
height_mm = float(spec.get("height_mm", 80))
depth_mm = float(spec.get("depth_mm", 40))
bleed_mm = float(spec.get("bleed_mm", 3))
elements = spec.get("elements", {})
brand = {**FOODLINKK_BRAND, **(spec.get("brand") or {})}
txt = _spec_text(spec)
if ptype == "folding_box":
canvas_w = _mm((width_mm * 2) + (depth_mm * 2) + 20)
canvas_h = _mm(height_mm + depth_mm + 20)
elif ptype == "wrap":
canvas_w = _mm(width_mm + 20)
canvas_h = _mm(height_mm + 20)
elif ptype == "round_label":
diameter = max(min(width_mm, height_mm), 20)
canvas_w = _mm(diameter + 20)
canvas_h = _mm(diameter + 20)
else:
raise ValueError(f"Unsupported packaging type: {ptype}")
canvas_w, canvas_h = _canvas_size(ptype, width_mm, height_mm, depth_mm)
dwg = svgwrite.Drawing(size=(canvas_w, canvas_h))
dwg.viewbox(0, 0, canvas_w, canvas_h)
# Background and frame
dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=brand["bg"]))
dwg.add(
dwg.rect(
@@ -85,141 +292,114 @@ def generate_packaging(spec: dict[str, Any]) -> str:
)
margin = 24
body_w = body_h = x0 = y0 = 0.0
if ptype == "folding_box":
body_w = _mm(width_mm)
body_h = _mm(height_mm)
depth_w = _mm(depth_mm)
x0 = margin
y0 = margin
x0, y0 = margin, margin
panels = [depth_w, body_w, depth_w, body_w]
x = x0
for idx, panel_w in enumerate(panels):
dwg.add(
dwg.rect(
insert=(x, y0),
size=(panel_w, body_h),
fill="none",
stroke=brand["primary"] if idx % 2 else brand["secondary"],
stroke_opacity=0.45,
stroke_width=1.6,
)
)
fill = brand["primary"] if idx % 2 else brand["secondary"]
dwg.add(dwg.rect(insert=(x, y0), size=(panel_w, body_h), fill="none", stroke=fill, stroke_opacity=0.45, stroke_width=1.6))
x += panel_w
if _elements_enabled(elements, "fold_lines"):
x = x0 + panels[0]
for panel_w in panels[1:]:
dwg.add(
dwg.line(
start=(x, y0),
end=(x, y0 + body_h),
stroke=brand["fold_line"],
stroke_dasharray="8,6",
stroke_width=1.2,
)
)
dwg.add(dwg.line(start=(x, y0), end=(x, y0 + body_h), stroke=brand["fold_line"], stroke_dasharray="8,6", stroke_width=1.2))
x += panel_w
if _elements_enabled(elements, "cut_lines"):
dwg.add(
dwg.rect(
insert=(x0, y0),
size=(sum(panels), body_h),
fill="none",
stroke=brand["cut_line"],
stroke_dasharray="5,4",
stroke_width=1.1,
)
)
dwg.add(dwg.rect(insert=(x0, y0), size=(sum(panels), body_h), fill="none", stroke=brand["cut_line"], stroke_dasharray="5,4", stroke_width=1.1))
if _elements_enabled(elements, "glue_tabs"):
tab_w, tab_h = _mm(12), _mm(8)
dwg.add(dwg.rect(insert=(x0 - tab_w, y0 + body_h * 0.4), size=(tab_w, tab_h), fill=brand["accent"], fill_opacity=0.35, stroke=brand["accent"]))
logo_x = x0 + panels[0] + (_mm(width_mm) * 0.1)
logo_y = y0 + (_mm(height_mm) * 0.12)
logo_w = _mm(width_mm) * 0.8
logo_h = _mm(height_mm) * 0.38
logo_x = x0 + panels[0] + (_mm(width_mm) * 0.12)
logo_y = y0 + (_mm(height_mm) * 0.16)
logo_w = _mm(width_mm) * 0.76
logo_h = _mm(height_mm) * 0.42
elif ptype == "wrap":
body_w, body_h = _mm(width_mm), _mm(height_mm)
x0, y0 = margin, margin
dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["primary"], stroke_width=2.2))
if _elements_enabled(elements, "fold_lines"):
dwg.add(dwg.line(start=(x0 + body_w / 2, y0), end=(x0 + body_w / 2, y0 + body_h), stroke=brand["fold_line"], stroke_dasharray="8,6", stroke_width=1.2))
if _elements_enabled(elements, "cut_lines"):
dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["cut_line"], stroke_dasharray="6,4", stroke_width=1.1))
logo_x, logo_y = x0 + body_w * 0.1, y0 + body_h * 0.12
logo_w, logo_h = body_w * 0.8, body_h * 0.35
elif ptype == "sleeve":
body_w, body_h = _mm(width_mm), _mm(height_mm)
depth_w = _mm(depth_mm)
x0, y0 = margin, margin
panels = [body_w, depth_w, body_w]
x = x0
for panel_w in panels:
dwg.add(dwg.rect(insert=(x, y0), size=(panel_w, body_h), fill="none", stroke=brand["primary"], stroke_width=1.8))
x += panel_w
if _elements_enabled(elements, "fold_lines"):
x = x0 + panels[0]
for panel_w in panels[1:]:
dwg.add(dwg.line(start=(x, y0), end=(x, y0 + body_h), stroke=brand["fold_line"], stroke_dasharray="8,6", stroke_width=1.2))
x += panel_w
logo_x, logo_y = x0 + body_w * 0.12, y0 + body_h * 0.15
logo_w, logo_h = body_w * 0.76, body_h * 0.4
elif ptype == "pouch":
body_w = _mm(width_mm)
body_h = _mm(height_mm)
x0 = margin
y0 = margin
dwg.add(
dwg.rect(
insert=(x0, y0),
size=(body_w, body_h),
fill="none",
stroke=brand["primary"],
stroke_width=2.2,
)
)
if _elements_enabled(elements, "cut_lines"):
dwg.add(
dwg.rect(
insert=(x0, y0),
size=(body_w, body_h),
fill="none",
stroke=brand["cut_line"],
stroke_dasharray="6,4",
stroke_width=1.1,
)
)
logo_x = x0 + (body_w * 0.14)
logo_y = y0 + (body_h * 0.14)
logo_w = body_w * 0.72
logo_h = body_h * 0.36
else:
seal_h = _mm(max(depth_mm, 15))
x0, y0 = margin, margin + seal_h
dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["primary"], stroke_width=2))
dwg.add(dwg.rect(insert=(x0, y0 - seal_h), size=(body_w, seal_h), fill=brand["secondary"], fill_opacity=0.2, stroke=brand["secondary"]))
dwg.add(dwg.text("SEAL", insert=(x0 + body_w / 2, y0 - seal_h / 2 + 4), fill=brand["text"], font_size=10, text_anchor="middle", font_family="Arial, sans-serif"))
logo_x, logo_y = x0 + body_w * 0.12, y0 + body_h * 0.18
logo_w, logo_h = body_w * 0.76, body_h * 0.42
elif ptype == "tray":
body_w, body_h = _mm(width_mm), _mm(height_mm)
lip = _mm(max(depth_mm, 8))
x0, y0 = margin, margin
dwg.add(dwg.rect(insert=(x0, y0), size=(body_w, body_h), fill="none", stroke=brand["primary"], stroke_width=2.4))
dwg.add(dwg.rect(insert=(x0 + lip, y0 + lip), size=(body_w - 2 * lip, body_h - 2 * lip), fill="none", stroke=brand["fold_line"], stroke_dasharray="5,4", stroke_width=1))
logo_x, logo_y = x0 + lip + 8, y0 + lip + 8
logo_w, logo_h = body_w - 2 * lip - 16, (body_h - 2 * lip) * 0.45
else: # round_label
diameter = min(canvas_w, canvas_h) - (margin * 2)
cx = canvas_w / 2
cy = canvas_h / 2
dwg.add(
dwg.circle(
center=(cx, cy),
r=diameter / 2,
fill="none",
stroke=brand["primary"],
stroke_width=2.4,
)
)
cx, cy = canvas_w / 2, canvas_h / 2
dwg.add(dwg.circle(center=(cx, cy), r=diameter / 2, fill="none", stroke=brand["primary"], stroke_width=2.4))
if _elements_enabled(elements, "cut_lines"):
dwg.add(
dwg.circle(
center=(cx, cy),
r=(diameter / 2) - 4,
fill="none",
stroke=brand["cut_line"],
stroke_dasharray="4,4",
stroke_width=1.0,
)
)
logo_w = diameter * 0.64
logo_h = diameter * 0.22
logo_x = cx - (logo_w / 2)
logo_y = cy - (logo_h / 2) - 8
body_w = diameter
body_h = diameter
x0 = cx - (diameter / 2)
y0 = cy - (diameter / 2)
dwg.add(dwg.circle(center=(cx, cy), r=(diameter / 2) - 4, fill="none", stroke=brand["cut_line"], stroke_dasharray="4,4", stroke_width=1.0))
logo_w, logo_h = diameter * 0.64, diameter * 0.22
logo_x, logo_y = cx - (logo_w / 2), cy - (logo_h / 2) - 8
body_w = body_h = diameter
x0, y0 = cx - (diameter / 2), cy - (diameter / 2)
if _elements_enabled(elements, "bleed"):
_draw_bleed(dwg, x0, y0, body_w if ptype != "folding_box" else sum([_mm(depth_mm), _mm(width_mm), _mm(depth_mm), _mm(width_mm)]), body_h, brand, bleed_mm)
if _elements_enabled(elements, "logo_area"):
dwg.add(
dwg.rect(
insert=(logo_x, logo_y),
size=(logo_w, logo_h),
rx=8,
ry=8,
fill="none",
stroke=brand["secondary"],
stroke_width=2,
)
)
dwg.add(
dwg.text(
"FOODLINKK",
insert=(logo_x + 12, logo_y + (logo_h / 2) + 5),
fill=brand["text"],
font_size=18,
font_family="Arial, sans-serif",
font_weight="bold",
)
)
_draw_logo_block(dwg, logo_x, logo_y, logo_w, logo_h, brand, txt)
if _elements_enabled(elements, "window"):
wx = x0 + body_w * 0.55 if ptype != "round_label" else logo_x + logo_w * 0.1
wy = y0 + body_h * 0.55 if ptype != "round_label" else logo_y + logo_h + 8
_draw_window(dwg, wx, wy, max(60, body_w * 0.32), max(40, body_h * 0.22))
if _elements_enabled(elements, "halal_badge"):
_draw_halal_badge(dwg, x0 + 8, y0 + 8, brand)
if _elements_enabled(elements, "nutrition_panel"):
nx = x0 + 8
ny = y0 + body_h - min(120, body_h * 0.45)
_draw_nutrition_panel(dwg, nx, ny, min(150, body_w * 0.42), min(110, body_h * 0.4), brand, _nutrition_rows(spec))
if _elements_enabled(elements, "ingredients") and txt["ingredients"]:
_draw_ingredients(dwg, x0 + 8, y0 + body_h - 28, body_w - 16, txt["ingredients"], brand)
if _elements_enabled(elements, "barcode"):
barcode_uri = _barcode_data_uri(str(spec.get("barcode_value") or DEFAULT_BARCODE_VALUE))
@@ -230,4 +410,23 @@ def generate_packaging(spec: dict[str, Any]) -> str:
dwg.add(dwg.rect(insert=(bar_x - 4, bar_y - 4), size=(bar_w + 8, bar_h + 8), fill="#ffffff"))
dwg.add(dwg.image(href=barcode_uri, insert=(bar_x, bar_y), size=(bar_w, bar_h)))
if _elements_enabled(elements, "qr_code"):
qr_uri = _qr_placeholder_svg(100)
qr_s = min(90, body_w * 0.22)
dwg.add(dwg.image(href=qr_uri, insert=(x0 + 10, y0 + body_h - qr_s - 10), size=(qr_s, qr_s)))
if _elements_enabled(elements, "dimensions"):
_draw_dimensions(dwg, x0, y0, body_w, body_h, spec, brand)
design_name = spec.get("design_name") or txt["product_name"]
dwg.add(
dwg.text(
f"Foodlinkk Packaging Studio · {design_name}"[:80],
insert=(12, canvas_h - 10),
fill=brand["muted"],
font_size=9,
font_family="Arial, sans-serif",
)
)
return dwg.tostring()
+252 -26
View File
@@ -1,7 +1,8 @@
"""Packaging generation API routes."""
"""Packaging generation API routes — persisted in PostgreSQL."""
from __future__ import annotations
from datetime import datetime
import json
from datetime import datetime, timezone
from typing import Any, Literal
from uuid import uuid4
@@ -9,58 +10,229 @@ from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
from pydantic import BaseModel, Field
from app.db import execute, fetch_all, fetch_one
from app.packaging.export import svg_to_pdf_bytes, svg_to_png_bytes
from app.packaging.generator import FOODLINKK_BRAND, generate_packaging
from app.packaging.generator import FOODLINKK_BRAND, PACKAGING_TYPES, generate_packaging
router = APIRouter(prefix="/packaging", tags=["packaging"])
_PROJECTS: dict[str, dict[str, Any]] = {}
class PackagingSpec(BaseModel):
type: Literal["folding_box", "wrap", "round_label"]
type: Literal["folding_box", "wrap", "round_label", "sleeve", "pouch", "tray"] = "folding_box"
width_mm: float = Field(default=120, gt=0, le=4000)
height_mm: float = Field(default=80, gt=0, le=4000)
depth_mm: float = Field(default=40, ge=0, le=4000)
bleed_mm: float = Field(default=3, ge=0, le=20)
elements: dict[str, bool] = Field(default_factory=dict)
brand: dict[str, str] = Field(default_factory=dict)
text: dict[str, Any] = Field(default_factory=dict)
barcode_value: str | None = Field(default=None, max_length=64)
qr_value: str | None = Field(default=None, max_length=512)
design_name: str | None = Field(default=None, max_length=120)
project_id: int | None = None
project_name: str | None = None
client_id: int | None = None
created_by: str = "ceo"
class PackagingUpdate(BaseModel):
spec: PackagingSpec
class CopyToProjectBody(BaseModel):
target_project_id: int = Field(..., gt=0)
created_by: str = "ceo"
class PackagingEmailBody(BaseModel):
to: list[str] = Field(..., min_length=1)
subject: str = Field(..., min_length=1, max_length=255)
body: str = ""
cc: list[str] = Field(default_factory=list)
attach_formats: list[str] = Field(default_factory=lambda: ["pdf"])
client_id: int | None = None
def _normalize_spec(spec_data: dict[str, Any]) -> dict[str, Any]:
if not spec_data.get("brand"):
spec_data["brand"] = dict(FOODLINKK_BRAND)
if not spec_data.get("elements"):
spec_data["elements"] = {
"barcode": True,
"logo_area": True,
"fold_lines": True,
"cut_lines": True,
"nutrition_panel": False,
"ingredients": False,
"halal_badge": False,
"window": False,
"qr_code": False,
"glue_tabs": False,
"bleed": True,
"dimensions": True,
}
return spec_data
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
out = dict(row)
spec = out.get("spec") or {}
if isinstance(spec, str):
try:
spec = json.loads(spec)
except Exception:
spec = {}
out["spec"] = spec
for key in ("created_at",):
if out.get(key) is not None and hasattr(out[key], "isoformat"):
out[key] = out[key].isoformat()
return out
def _get_project(project_id: str) -> dict[str, Any] | None:
row = fetch_one(
"""
SELECT po.*, cp.name AS project_name, cp.nas_path, c.name AS client_name, c.id AS client_id
FROM packaging_outputs po
LEFT JOIN cockpit_projects cp ON cp.id = po.project_id
LEFT JOIN clients c ON c.id = cp.client_id
WHERE po.id = %s
""",
(project_id,),
)
return _serialize_row(dict(row)) if row else None
def _link_asset(cockpit_project_id: int, packaging_id: str, spec_data: dict[str, Any], created_by: str, file_path: str | None = None) -> None:
title = spec_data.get("design_name") or spec_data.get("text", {}).get("product_name") or f"Packaging {spec_data.get('type')}"
existing = fetch_one(
"SELECT id FROM project_assets WHERE project_id = %s AND asset_type = 'packaging' AND ref_id = %s LIMIT 1",
(cockpit_project_id, packaging_id),
)
payload = json.dumps({"packaging_id": packaging_id, "spec": spec_data})
if existing:
execute(
"""
UPDATE project_assets SET title = %s, payload = %s::jsonb, file_path = COALESCE(%s, file_path)
WHERE id = %s
""",
(title, payload, file_path, existing["id"]),
)
else:
fetch_one(
"""
INSERT INTO project_assets (project_id, asset_type, ref_id, title, file_path, payload, created_by, source_agent)
VALUES (%s, 'packaging', %s, %s, %s, %s::jsonb, %s, 'packaging')
RETURNING id
""",
(cockpit_project_id, packaging_id, title, file_path, payload, created_by),
)
execute("UPDATE cockpit_projects SET updated_at = NOW() WHERE id = %s", (cockpit_project_id,))
def _resolve_cockpit_project(spec: PackagingSpec) -> int | None:
cockpit_project_id = spec.project_id
if spec.project_name and not cockpit_project_id:
row = fetch_one(
"""
INSERT INTO cockpit_projects (name, client_id, description, created_by, project_type, updated_at)
VALUES (%s, %s, %s, %s, 'packaging', NOW())
RETURNING id
""",
(spec.project_name.strip(), spec.client_id, "Packaging project", spec.created_by),
)
cockpit_project_id = int(row["id"]) if row else None
return cockpit_project_id
def _persist_packaging(spec: PackagingSpec, packaging_id: str | None = None) -> dict[str, Any]:
spec_data = _normalize_spec(spec.model_dump(exclude={"project_id", "project_name", "client_id", "created_by"}))
svg = generate_packaging(spec_data)
pid = packaging_id or uuid4().hex
cockpit_project_id = _resolve_cockpit_project(spec)
existing = _get_project(pid) if packaging_id else None
if existing:
fetch_one(
"""
UPDATE packaging_outputs SET project_id = %s, spec = %s::jsonb, svg = %s, created_by = %s
WHERE id = %s RETURNING id
""",
(cockpit_project_id, json.dumps(spec_data), svg, spec.created_by, pid),
)
else:
fetch_one(
"""
INSERT INTO packaging_outputs (id, project_id, spec, svg, created_by, created_at)
VALUES (%s, %s, %s::jsonb, %s, %s, NOW())
RETURNING id
""",
(pid, cockpit_project_id, json.dumps(spec_data), svg, spec.created_by),
)
if cockpit_project_id:
_link_asset(cockpit_project_id, pid, spec_data, spec.created_by)
return {
"id": pid,
"cockpit_project_id": cockpit_project_id,
"created_at": datetime.now(timezone.utc).isoformat(),
"svg": svg,
"spec": spec_data,
}
@router.get("/types")
def packaging_types() -> dict[str, Any]:
labels = {
"folding_box": "Folding box (sluitdoos)",
"wrap": "Wrap / banderole",
"round_label": "Rond label",
"sleeve": "Sleeve / huls",
"pouch": "Pouch / zak",
"tray": "Tray / schaal",
}
return {"items": [{"id": t, "label": labels.get(t, t)} for t in PACKAGING_TYPES]}
@router.post("/preview")
def packaging_preview(spec: PackagingSpec) -> dict[str, Any]:
spec_data = _normalize_spec(spec.model_dump(exclude={"project_id", "project_name", "client_id", "created_by"}))
return {"svg": generate_packaging(spec_data), "spec": spec_data}
@router.post("/generate")
def packaging_generate(spec: PackagingSpec) -> dict[str, Any]:
spec_data = spec.model_dump()
if not spec_data["brand"]:
spec_data["brand"] = dict(FOODLINKK_BRAND)
svg = generate_packaging(spec_data)
project_id = uuid4().hex
now = datetime.utcnow().isoformat() + "Z"
_PROJECTS[project_id] = {
"id": project_id,
"created_at": now,
"spec": spec_data,
"svg": svg,
}
return {"id": project_id, "created_at": now, "svg": svg, "spec": spec_data}
return _persist_packaging(spec)
@router.get("/projects")
def packaging_projects(limit: int = Query(default=30, ge=1, le=200)) -> dict[str, Any]:
items = sorted(_PROJECTS.values(), key=lambda x: x["created_at"], reverse=True)[:limit]
return {
"items": [{"id": p["id"], "created_at": p["created_at"], "spec": p["spec"]} for p in items],
"count": len(items),
}
rows = fetch_all(
"""
SELECT po.id, po.project_id AS cockpit_project_id, po.spec, po.created_by, po.created_at,
cp.name AS project_name, cp.nas_path, c.name AS client_name
FROM packaging_outputs po
LEFT JOIN cockpit_projects cp ON cp.id = po.project_id
LEFT JOIN clients c ON c.id = cp.client_id
ORDER BY po.created_at DESC
LIMIT %s
""",
(limit,),
)
return {"items": [_serialize_row(dict(r)) for r in rows], "count": len(rows)}
@router.get("/download/{project_id}")
def packaging_download(project_id: str, format: str = Query(default="svg", pattern="^(svg|png|pdf)$")):
project = _PROJECTS.get(project_id)
project = _get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="Packaging project not found")
svg = project["svg"]
filename = f"foodlinkk-packaging-{project_id[:8]}.{format}"
slug = (project.get("spec") or {}).get("design_name") or project_id[:8]
safe_slug = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in slug.lower())[:40]
filename = f"foodlinkk-{safe_slug}.{format}"
if format == "svg":
return Response(
content=svg.encode("utf-8"),
@@ -78,3 +250,57 @@ def packaging_download(project_id: str, format: str = Query(default="svg", patte
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/{packaging_id}")
def packaging_get(packaging_id: str) -> dict[str, Any]:
row = _get_project(packaging_id)
if not row:
raise HTTPException(status_code=404, detail="Packaging design not found")
return {"item": row}
@router.patch("/{packaging_id}")
def packaging_update(packaging_id: str, body: PackagingUpdate) -> dict[str, Any]:
if not _get_project(packaging_id):
raise HTTPException(status_code=404, detail="Packaging design not found")
existing = _get_project(packaging_id) or {}
spec = body.spec
if not spec.project_id and existing.get("project_id"):
spec.project_id = int(existing["project_id"])
result = _persist_packaging(spec, packaging_id=packaging_id)
return result
@router.post("/{packaging_id}/duplicate")
def packaging_duplicate(packaging_id: str, created_by: str = Query(default="ceo")) -> dict[str, Any]:
row = _get_project(packaging_id)
if not row:
raise HTTPException(status_code=404, detail="Packaging design not found")
spec = dict(row.get("spec") or {})
spec["design_name"] = (spec.get("design_name") or spec.get("text", {}).get("product_name") or "Design") + " (kopie)"
payload = PackagingSpec(**{**spec, "project_id": int(row["project_id"]) if row.get("project_id") else None, "created_by": created_by})
return _persist_packaging(payload)
@router.post("/{packaging_id}/copy-to-project")
def packaging_copy_to_project(packaging_id: str, body: CopyToProjectBody) -> dict[str, Any]:
row = _get_project(packaging_id)
if not row:
raise HTTPException(status_code=404, detail="Packaging design not found")
target = fetch_one("SELECT id, name FROM cockpit_projects WHERE id = %s", (body.target_project_id,))
if not target:
raise HTTPException(status_code=404, detail="Target project not found")
spec = dict(row.get("spec") or {})
spec["design_name"] = (spec.get("design_name") or "Design") + f"{target['name']}"
payload = PackagingSpec(**{**spec, "project_id": body.target_project_id, "created_by": body.created_by})
return _persist_packaging(payload)
@router.delete("/{packaging_id}")
def packaging_delete(packaging_id: str) -> dict[str, Any]:
if not _get_project(packaging_id):
raise HTTPException(status_code=404, detail="Packaging design not found")
execute("DELETE FROM project_assets WHERE asset_type = 'packaging' AND ref_id = %s", (packaging_id,))
execute("DELETE FROM packaging_outputs WHERE id = %s", (packaging_id,))
return {"ok": True, "id": packaging_id}
+19
View File
@@ -177,6 +177,25 @@ def run_research() -> dict[str, Any]:
title="Full research cycle completed",
metadata={"briefs": 3, "snapshots": len(snapshot_ids)},
)
from app.connectors.project_assets import register_agent_output
for brief, domain in [
(crm_brief, "crm"),
(retail_brief, "retail"),
(social_brief, "social"),
]:
try:
register_agent_output(
asset_type="research_brief",
title=brief.get("title") or f"Research {domain}",
ref_id=str(brief.get("id")),
payload={"domain": domain, "summary": brief.get("summary"), "brief_id": brief.get("id")},
source_agent="research",
)
except Exception:
pass
return {
"snapshots": snapshot_ids,
"briefs": [crm_brief["id"], retail_brief["id"], social_brief["id"]],
+98 -1
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.db import execute_returning, fetch_all, fetch_one
from app.db import execute, execute_returning, fetch_all, fetch_one
from app.middleware import log_agent_event
from app import retail_scrapers
from app import retail_enrichment
@@ -35,6 +35,49 @@ class CrmLinkIn(BaseModel):
notes: Optional[str] = None
class SupermarketCreateIn(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
data_source: str = "manual"
class SupermarketPatchIn(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
STORE_SELECT = """
SELECT s.*,
a.population AS area_population,
@@ -360,6 +403,60 @@ def get_supermarket(store_id: int) -> dict[str, Any]:
return data
@router.post("/supermarkets")
def create_supermarket(payload: SupermarketCreateIn) -> dict[str, Any]:
row = execute_returning(
"""INSERT INTO supermarkets (
name, chain, address, postcode, city, province, phone, email, website,
manager_name, employee_count, store_type, partnership_status,
halal_certified, has_halal_section, halal_certifier, data_source, last_updated
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW())
RETURNING id""",
(
payload.name.strip(), payload.chain.strip(), payload.address.strip(),
payload.postcode.strip().upper(), payload.city.strip(), payload.province,
payload.phone, payload.email, payload.website, payload.manager_name,
payload.employee_count, payload.store_type, payload.partnership_status,
payload.halal_certified, payload.has_halal_section, payload.halal_certifier,
payload.data_source,
),
)
store_id = int(row["id"])
log_agent_event(
agent_name="retail_crm",
event_type="create",
title=f"Handmatig filiaal toegevoegd: {payload.name} ({payload.chain})",
)
return {"ok": True, "item": _row(fetch_one(f"{STORE_SELECT} WHERE s.id = %s", (store_id,)))}
@router.patch("/supermarkets/{store_id}")
def patch_supermarket(store_id: int, payload: SupermarketPatchIn) -> dict[str, Any]:
existing = fetch_one("SELECT id FROM supermarkets WHERE id = %s", (store_id,))
if not existing:
raise HTTPException(404, "Supermarket not found")
data = payload.model_dump(exclude_unset=True)
if not data:
raise HTTPException(400, "Geen velden om bij te werken")
allowed = set(SupermarketPatchIn.model_fields.keys())
sets: list[str] = []
params: list[Any] = []
for key, val in data.items():
if key not in allowed:
continue
sets.append(f"{key} = %s")
params.append(val)
sets.append("last_updated = NOW()")
params.append(store_id)
execute(f"UPDATE supermarkets SET {', '.join(sets)} WHERE id = %s", tuple(params))
log_agent_event(
agent_name="retail_crm",
event_type="update",
title=f"Supermarkt #{store_id} data aangevuld",
)
return {"ok": True, "item": _row(fetch_one(f"{STORE_SELECT} WHERE s.id = %s", (store_id,)))}
@router.get("/scrape/chains")
def list_scrape_chains() -> dict[str, Any]:
return {"chains": retail_scrapers.list_chains()}
+34
View File
@@ -94,3 +94,37 @@ def list_crm_options() -> dict[str, Any]:
WHERE d.stage NOT IN ('won','lost') ORDER BY d.updated_at DESC LIMIT 200"""
)
return {"clients": [dict(c) for c in clients], "deals": [dict(d) for d in deals]}
def get_client_retail_links(client_id: int) -> list[dict[str, Any]]:
rows = 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[str, Any]] = {}
for row in list(rows) + list(direct):
merged[int(row["id"])] = dict(row)
return list(merged.values())