Platform bundle: marketing publish, IT ops, packaging, agents mesh.
Volledige Foodlinkk Command Center uitbreiding met social automatisering, reclamefolder filters, Proxmox monitoring en documentatie.
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
"""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"}
|
||||
Reference in New Issue
Block a user