Files
foodlinkk-command-center/cockpit/app/routes/settings_api.py
T
Aissa 5d60d33db1 Platform bundle: marketing publish, IT ops, packaging, agents mesh.
Volledige Foodlinkk Command Center uitbreiding met social automatisering,
reclamefolder filters, Proxmox monitoring en documentatie.
2026-06-09 00:41:27 +00:00

384 lines
13 KiB
Python

"""Settings API — email accounts stored in PostgreSQL."""
from __future__ import annotations
import smtplib
import json
from datetime import datetime
from email.mime.text import MIMEText
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.db import execute, fetch_all, fetch_one
from app.services import agent_souls
from app.services.social_publish import PLATFORMS, get_integration, test_connection
settings_router = APIRouter(prefix="/api/settings", tags=["settings"])
class EmailAccountBody(BaseModel):
label: str = Field(..., max_length=128)
email_address: str = Field(..., max_length=255)
provider: str = Field(default="custom", max_length=32)
is_active: bool = False
smtp_host: Optional[str] = None
smtp_port: int = 587
smtp_user: Optional[str] = None
smtp_password: Optional[str] = None
imap_host: Optional[str] = None
imap_port: int = 993
imap_user: Optional[str] = None
imap_password: Optional[str] = None
sync_enabled: bool = False
def _mask_account(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
out["smtp_password_set"] = bool(row.get("smtp_password"))
out["imap_password_set"] = bool(row.get("imap_password"))
out.pop("smtp_password", None)
out.pop("imap_password", None)
return out
def _deactivate_all() -> None:
execute("UPDATE email_accounts SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE")
def _test_smtp_config(
smtp_host: str,
smtp_port: int,
smtp_user: str,
smtp_pass: str,
from_addr: str,
) -> tuple[bool, str]:
if not smtp_host or not from_addr:
return False, "SMTP host en from-adres zijn verplicht"
try:
msg = MIMEText("Foodlinkk SMTP test — Herman email settings OK.", "plain", "utf-8")
msg["Subject"] = "Foodlinkk test email"
msg["From"] = from_addr
msg["To"] = from_addr
with smtplib.SMTP(smtp_host, smtp_port, timeout=25) as server:
server.ehlo()
if smtp_port == 587:
server.starttls()
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.sendmail(from_addr, [from_addr], msg.as_string())
return True, f"Testmail verstuurd naar {from_addr}"
except Exception as exc:
return False, str(exc)
def _resolve_password(new: Optional[str], existing: Optional[str]) -> Optional[str]:
if new is not None and new != "":
return new
return existing
SOCIAL_PLATFORM_FIELDS: dict[str, tuple[str, ...]] = {
"twitter": ("api_key", "api_secret", "access_token", "access_secret"),
"linkedin": ("access_token", "person_urn"),
"instagram": ("access_token", "page_id"),
"facebook": ("access_token", "page_id"),
"tiktok": ("access_token", "open_id"),
"pinterest": ("access_token", "board_id"),
}
SOCIAL_SECRET_FIELDS = {"api_secret", "access_secret", "access_token", "api_key"}
class SocialIntegrationBody(BaseModel):
api_key: Optional[str] = None
api_secret: Optional[str] = None
access_token: Optional[str] = None
access_secret: Optional[str] = None
person_urn: Optional[str] = None
page_id: Optional[str] = None
open_id: Optional[str] = None
board_id: Optional[str] = None
is_active: Optional[bool] = None
def _mask_social_row(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
config = out.get("config") or {}
if isinstance(config, str):
try:
config = json.loads(config)
except Exception:
config = {}
if not isinstance(config, dict):
config = {}
masked = dict(config)
for key in SOCIAL_SECRET_FIELDS:
if key in config:
masked[f"{key}_set"] = bool(config.get(key))
masked.pop(key, None)
out["config"] = masked
return out
def _normalize_social_platform(platform: str) -> str:
value = (platform or "").strip().lower()
if value not in PLATFORMS:
raise HTTPException(status_code=400, detail=f"Unsupported platform: {platform}")
return value
@settings_router.get("/email")
def list_email_accounts() -> dict[str, Any]:
try:
rows = fetch_all("SELECT * FROM email_accounts ORDER BY is_active DESC, updated_at DESC")
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"accounts": [_mask_account(r) for r in rows]}
@settings_router.post("/email")
def create_email_account(body: EmailAccountBody) -> dict[str, Any]:
if body.is_active:
_deactivate_all()
try:
row = fetch_one(
"""
INSERT INTO email_accounts (
label, email_address, provider, is_active,
smtp_host, smtp_port, smtp_user, smtp_password,
imap_host, imap_port, imap_user, imap_password, sync_enabled
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""",
(
body.label,
body.email_address,
body.provider,
body.is_active,
body.smtp_host,
body.smtp_port,
body.smtp_user or body.email_address,
body.smtp_password or "",
body.imap_host,
body.imap_port,
body.imap_user or body.email_address,
body.imap_password or "",
body.sync_enabled,
),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "account": _mask_account(row)}
@settings_router.put("/email/{account_id}")
def update_email_account(account_id: int, body: EmailAccountBody) -> dict[str, Any]:
existing = fetch_one("SELECT * FROM email_accounts WHERE id = %s", (account_id,))
if not existing:
raise HTTPException(status_code=404, detail="Account not found")
if body.is_active:
_deactivate_all()
smtp_pass = _resolve_password(body.smtp_password, existing.get("smtp_password"))
imap_pass = _resolve_password(body.imap_password, existing.get("imap_password"))
try:
row = fetch_one(
"""
UPDATE email_accounts SET
label=%s, email_address=%s, provider=%s, is_active=%s,
smtp_host=%s, smtp_port=%s, smtp_user=%s, smtp_password=%s,
imap_host=%s, imap_port=%s, imap_user=%s, imap_password=%s,
sync_enabled=%s, updated_at=NOW()
WHERE id=%s RETURNING *
""",
(
body.label,
body.email_address,
body.provider,
body.is_active,
body.smtp_host,
body.smtp_port,
body.smtp_user or body.email_address,
smtp_pass,
body.imap_host,
body.imap_port,
body.imap_user or body.email_address,
imap_pass,
body.sync_enabled,
account_id,
),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "account": _mask_account(row)}
@settings_router.delete("/email/{account_id}")
def delete_email_account(account_id: int) -> dict[str, Any]:
execute("DELETE FROM email_accounts WHERE id = %s", (account_id,))
return {"ok": True}
@settings_router.post("/email/{account_id}/activate")
def activate_email_account(account_id: int) -> dict[str, Any]:
existing = fetch_one("SELECT id FROM email_accounts WHERE id = %s", (account_id,))
if not existing:
raise HTTPException(status_code=404, detail="Account not found")
_deactivate_all()
row = fetch_one(
"UPDATE email_accounts SET is_active=TRUE, updated_at=NOW() WHERE id=%s RETURNING *",
(account_id,),
)
return {"ok": True, "account": _mask_account(row)}
@settings_router.post("/email/{account_id}/test")
def test_saved_email_account(account_id: int) -> dict[str, Any]:
row = fetch_one("SELECT * FROM email_accounts WHERE id = %s", (account_id,))
if not row:
raise HTTPException(status_code=404, detail="Account not found")
ok, message = _test_smtp_config(
row.get("smtp_host") or "",
int(row.get("smtp_port") or 587),
row.get("smtp_user") or row.get("email_address") or "",
row.get("smtp_password") or "",
row.get("email_address") or row.get("smtp_user") or "",
)
status = "ok" if ok else "failed"
execute(
"""
UPDATE email_accounts SET last_test_at=NOW(), last_test_status=%s, last_test_message=%s, updated_at=NOW()
WHERE id=%s
""",
(status, message[:500], account_id),
)
return {"ok": ok, "message": message}
@settings_router.post("/email/test")
def test_email_config(body: EmailAccountBody) -> dict[str, Any]:
"""Test SMTP without saving (form preview)."""
if not body.smtp_password:
raise HTTPException(status_code=400, detail="SMTP wachtwoord is verplicht voor test zonder opgeslagen account")
ok, message = _test_smtp_config(
body.smtp_host or "",
body.smtp_port,
body.smtp_user or body.email_address,
body.smtp_password,
body.email_address,
)
return {"ok": ok, "message": message}
@settings_router.get("/social")
def list_social_integrations() -> dict[str, Any]:
rows = fetch_all("SELECT * FROM social_integrations ORDER BY platform")
return {"items": [_mask_social_row(r) for r in rows], "platforms": list(PLATFORMS)}
@settings_router.put("/social/{platform}")
def save_social_integration(platform: str, body: SocialIntegrationBody) -> dict[str, Any]:
platform = _normalize_social_platform(platform)
allowed_fields = set(SOCIAL_PLATFORM_FIELDS[platform])
incoming = body.model_dump(exclude_none=True)
existing = fetch_one("SELECT * FROM social_integrations WHERE platform = %s", (platform,))
existing_config = {}
if existing:
existing_config = existing.get("config") or {}
if isinstance(existing_config, str):
try:
existing_config = json.loads(existing_config)
except Exception:
existing_config = {}
if not isinstance(existing_config, dict):
existing_config = {}
config = dict(existing_config)
for field in allowed_fields:
if field not in incoming:
continue
value = incoming.get(field)
if field in SOCIAL_SECRET_FIELDS:
if value is not None and value != "":
config[field] = value
else:
config[field] = value
is_active = body.is_active
if is_active is None:
is_active = bool(existing.get("is_active")) if existing else True
row = fetch_one(
"""
INSERT INTO social_integrations (platform, config, is_active, updated_at)
VALUES (%s, %s::jsonb, %s, NOW())
ON CONFLICT (platform) DO UPDATE SET
config = EXCLUDED.config,
is_active = EXCLUDED.is_active,
updated_at = NOW()
RETURNING *
""",
(platform, json.dumps(config), is_active),
)
return {"ok": True, "integration": _mask_social_row(row)}
@settings_router.post("/social/{platform}/test")
def test_social_integration(platform: str) -> dict[str, Any]:
platform = _normalize_social_platform(platform)
integration = get_integration(platform)
if not integration:
raise HTTPException(status_code=404, detail="Integration not configured")
result = test_connection(platform, integration)
status = "ok" if result.get("ok") else "failed"
message = (result.get("message") or result.get("error") or "")[:500]
try:
execute(
"""
UPDATE social_integrations
SET last_test_at=NOW(), last_test_status=%s, last_test_message=%s, updated_at=NOW()
WHERE platform=%s
""",
(status, message, platform),
)
except Exception:
pass
return {"platform": platform, **result}
class PermissionBody(BaseModel):
granted: bool
@settings_router.get("/permissions")
def list_permissions() -> dict[str, Any]:
items = agent_souls.list_permissions()
granted = sum(1 for i in items if i.get("granted"))
return {"items": items, "granted_count": granted, "total": len(items)}
@settings_router.put("/permissions/{module_key}")
def update_permission(module_key: str, body: PermissionBody) -> dict[str, Any]:
row = agent_souls.update_permission(module_key, body.granted)
if not row:
raise HTTPException(404, "Module not found")
return {"permission": row}
@settings_router.post("/permissions/grant-all")
def grant_all_permissions() -> dict[str, Any]:
n = agent_souls.grant_all_permissions()
return {"ok": True, "granted_count": n, "message": f"Herman heeft nu {n} module-rechten"}