5d60d33db1
Volledige Foodlinkk Command Center uitbreiding met social automatisering, reclamefolder filters, Proxmox monitoring en documentatie.
384 lines
13 KiB
Python
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"}
|