Files
pngx-sync/app/api/settings.py
domverse b99dbf694d
All checks were successful
Deploy / deploy (push) Successful in 30s
feat: implement pngx-controller with Gitea CI/CD deployment
- Full FastAPI sync engine: master→replica document sync via paperless REST API
- Web UI: dashboard, replicas, logs, settings (Jinja2 + HTMX + Pico CSS)
- APScheduler background sync, SSE live log stream, Prometheus metrics
- Fernet encryption for API tokens at rest
- pngx.env credential file: written on save, pre-fills forms on load
- Dockerfile with layer-cached uv build, Python healthcheck
- docker-compose with host networking for Tailscale access
- Gitea Actions workflow: version bump, secret injection, docker compose deploy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 17:59:25 +01:00

181 lines
6.0 KiB
Python

import asyncio
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import Session, select
from ..config import get_config
from ..crypto import decrypt, encrypt
from ..database import get_session
from ..models import Setting
from ..scheduler import SETTINGS_DEFAULTS
router = APIRouter(prefix="/api/settings", tags=["settings"])
ENCRYPTED_KEYS = {"master_token", "alert_target_token"}
def _get_all_settings(session: Session) -> dict:
rows = session.exec(select(Setting)).all()
result = dict(SETTINGS_DEFAULTS)
for row in rows:
if row.value is not None:
result[row.key] = row.value
return result
def _safe_settings(settings: dict) -> dict:
"""Return settings with encrypted values masked."""
out = dict(settings)
for k in ENCRYPTED_KEYS:
if out.get(k):
out[k] = "••••••••"
return out
@router.get("")
def get_settings(session: Session = Depends(get_session)):
return _safe_settings(_get_all_settings(session))
class SettingsUpdate(BaseModel):
master_url: str | None = None
master_token: str | None = None
sync_interval_seconds: int | None = None
log_retention_days: int | None = None
sync_cycle_timeout_seconds: int | None = None
task_poll_timeout_seconds: int | None = None
replica_suspend_threshold: int | None = None
max_concurrent_requests: int | None = None
alert_target_type: str | None = None
alert_target_url: str | None = None
alert_target_token: str | None = None
alert_error_threshold: int | None = None
alert_cooldown_seconds: int | None = None
@router.put("")
async def update_settings(
body: SettingsUpdate,
session: Session = Depends(get_session),
):
config = get_config()
updates = body.model_dump(exclude_none=True)
# Validate master connection if URL or token changed
current = _get_all_settings(session)
if "master_url" in updates or "master_token" in updates:
new_url = updates.get("master_url") or current.get("master_url", "")
new_token = updates.get("master_token")
if not new_token:
enc = current.get("master_token", "")
new_token = decrypt(enc, config.secret_key) if enc else ""
if new_url and new_token:
import httpx as _httpx
try:
async with _httpx.AsyncClient(
headers={"Authorization": f"Token {new_token}"},
timeout=10.0,
) as _client:
_r = await _client.get(
new_url.rstrip("/") + "/api/documents/",
params={"page_size": 1},
)
_r.raise_for_status()
except Exception as _e:
raise HTTPException(
422,
detail=f"Master connection test failed: {_e}",
)
# Capture plaintext values for envfile before encryption
env_updates: dict[str, str] = {}
if "master_url" in updates:
env_updates["MASTER_URL"] = str(updates["master_url"])
if "master_token" in updates and updates["master_token"]:
env_updates["MASTER_TOKEN"] = str(updates["master_token"])
# Persist updates
for key, value in updates.items():
if key in ENCRYPTED_KEYS and value:
value = encrypt(str(value), config.secret_key)
setting = session.get(Setting, key)
if setting:
setting.value = str(value)
else:
setting = Setting(key=key, value=str(value))
session.add(setting)
session.commit()
if env_updates:
from .. import envfile
envfile.write(env_updates)
# Reschedule if interval changed
if "sync_interval_seconds" in updates:
from ..scheduler import reschedule
reschedule(int(updates["sync_interval_seconds"]))
return _safe_settings(_get_all_settings(session))
class ConnectionTestRequest(BaseModel):
url: str
token: str = "" # blank = use saved master token
@router.post("/test")
async def test_connection(
body: ConnectionTestRequest,
session: Session = Depends(get_session),
):
"""Test a connection using the provided URL and token (does not save).
If token is blank, falls back to the saved master_token."""
import httpx
import time
config = get_config()
token = body.token.strip()
if not token:
settings = _get_all_settings(session)
enc = settings.get("master_token", "")
token = decrypt(enc, config.secret_key) if enc else ""
if not token:
return {"ok": False, "error": "No token provided and no saved token found", "latency_ms": 0, "doc_count": 0}
t0 = time.monotonic()
try:
async with httpx.AsyncClient(
headers={"Authorization": f"Token {token}"},
timeout=10.0,
) as client:
r = await client.get(
body.url.rstrip("/") + "/api/documents/",
params={"page_size": 1},
)
r.raise_for_status()
elapsed = int((time.monotonic() - t0) * 1000)
data = r.json()
return {"ok": True, "error": None, "latency_ms": elapsed, "doc_count": data.get("count", 0)}
except Exception as e:
return {"ok": False, "error": str(e), "latency_ms": 0, "doc_count": 0}
@router.get("/status")
async def master_status(session: Session = Depends(get_session)):
"""Test the currently saved master connection."""
config = get_config()
settings = _get_all_settings(session)
master_url = settings.get("master_url", "")
master_token_enc = settings.get("master_token", "")
if not master_url or not master_token_enc:
return {"ok": False, "error": "Not configured", "latency_ms": 0, "doc_count": 0}
master_token = decrypt(master_token_enc, config.secret_key)
from ..sync.paperless import PaperlessClient
sem = asyncio.Semaphore(1)
async with PaperlessClient(master_url, master_token, sem) as client:
return await client.test_connection()