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