feat: implement pngx-controller with Gitea CI/CD deployment
All checks were successful
Deploy / deploy (push) Successful in 30s
All checks were successful
Deploy / deploy (push) Successful in 30s
- 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>
This commit is contained in:
180
app/api/settings.py
Normal file
180
app/api/settings.py
Normal file
@@ -0,0 +1,180 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user