Files
pngx-sync/app/api/replicas.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

197 lines
6.1 KiB
Python

import asyncio
from typing import Optional
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 Replica, SyncMap
from ..sync.paperless import PaperlessClient
router = APIRouter(prefix="/api/replicas", tags=["replicas"])
class ReplicaCreate(BaseModel):
name: str
url: str
api_token: str
enabled: bool = True
sync_interval_seconds: Optional[int] = None
class ReplicaUpdate(BaseModel):
name: Optional[str] = None
url: Optional[str] = None
api_token: Optional[str] = None
enabled: Optional[bool] = None
sync_interval_seconds: Optional[int] = None
def _serialize(r: Replica) -> dict:
return {
"id": r.id,
"name": r.name,
"url": r.url,
"enabled": r.enabled,
"sync_interval_seconds": r.sync_interval_seconds,
"last_sync_ts": r.last_sync_ts.isoformat() if r.last_sync_ts else None,
"consecutive_failures": r.consecutive_failures,
"suspended_at": r.suspended_at.isoformat() if r.suspended_at else None,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
async def _test_conn(url: str, token: str) -> dict:
sem = asyncio.Semaphore(1)
async with PaperlessClient(url, token, sem) as client:
return await client.test_connection()
@router.get("")
def list_replicas(session: Session = Depends(get_session)):
return [_serialize(r) for r in session.exec(select(Replica)).all()]
@router.post("", status_code=201)
async def create_replica(
body: ReplicaCreate, session: Session = Depends(get_session)
):
result = await _test_conn(body.url, body.api_token)
if not result["ok"]:
raise HTTPException(422, detail=f"Connection test failed: {result['error']}")
config = get_config()
encrypted_token = encrypt(body.api_token, config.secret_key)
replica = Replica(
name=body.name,
url=body.url,
api_token=encrypted_token,
enabled=body.enabled,
sync_interval_seconds=body.sync_interval_seconds,
)
session.add(replica)
session.commit()
session.refresh(replica)
from .. import envfile
url_key, token_key = envfile.replica_keys(replica.name)
envfile.write({url_key: replica.url, token_key: body.api_token})
response = _serialize(replica)
response["doc_count"] = result["doc_count"]
return response
@router.put("/{replica_id}")
async def update_replica(
replica_id: int,
body: ReplicaUpdate,
session: Session = Depends(get_session),
):
replica = session.get(Replica, replica_id)
if not replica:
raise HTTPException(404)
config = get_config()
url_changed = body.url is not None and body.url != replica.url
token_changed = body.api_token is not None
if url_changed or token_changed:
new_url = body.url or replica.url
new_token = body.api_token or decrypt(replica.api_token, config.secret_key)
result = await _test_conn(new_url, new_token)
if not result["ok"]:
raise HTTPException(422, detail=f"Connection test failed: {result['error']}")
if body.name is not None:
replica.name = body.name
if body.url is not None:
replica.url = body.url
if body.api_token is not None:
replica.api_token = encrypt(body.api_token, config.secret_key)
if body.enabled is not None:
replica.enabled = body.enabled
if body.sync_interval_seconds is not None:
replica.sync_interval_seconds = body.sync_interval_seconds
session.add(replica)
session.commit()
session.refresh(replica)
from .. import envfile
url_key, token_key = envfile.replica_keys(replica.name)
env_write: dict[str, str] = {url_key: replica.url}
if body.api_token:
env_write[token_key] = body.api_token
envfile.write(env_write)
return _serialize(replica)
@router.delete("/{replica_id}", status_code=204)
def delete_replica(replica_id: int, session: Session = Depends(get_session)):
replica = session.get(Replica, replica_id)
if not replica:
raise HTTPException(404)
# Explicitly delete sync_map rows before the replica (SQLite FK cascade)
for entry in session.exec(select(SyncMap).where(SyncMap.replica_id == replica_id)).all():
session.delete(entry)
session.delete(replica)
session.commit()
@router.post("/{replica_id}/test")
async def test_replica(replica_id: int, session: Session = Depends(get_session)):
replica = session.get(Replica, replica_id)
if not replica:
raise HTTPException(404)
config = get_config()
token = decrypt(replica.api_token, config.secret_key)
return await _test_conn(replica.url, token)
@router.post("/{replica_id}/reconcile")
async def reconcile_replica(replica_id: int, session: Session = Depends(get_session)):
replica = session.get(Replica, replica_id)
if not replica:
raise HTTPException(404)
from ..sync.reconcile import run_reconcile
result = await run_reconcile(replica_id)
return result
@router.post("/{replica_id}/unsuspend")
def unsuspend_replica(replica_id: int, session: Session = Depends(get_session)):
replica = session.get(Replica, replica_id)
if not replica:
raise HTTPException(404)
replica.suspended_at = None
replica.consecutive_failures = 0
session.add(replica)
session.commit()
return _serialize(replica)
@router.post("/{replica_id}/resync")
async def resync_replica(replica_id: int, session: Session = Depends(get_session)):
"""Phase 3: wipe sync_map and trigger full resync."""
replica = session.get(Replica, replica_id)
if not replica:
raise HTTPException(404)
# Delete all sync_map entries for this replica
entries = session.exec(
select(SyncMap).where(SyncMap.replica_id == replica_id)
).all()
for e in entries:
session.delete(e)
session.commit()
# Trigger sync
from ..sync.engine import run_sync_cycle
started = await run_sync_cycle(triggered_by="manual", replica_id=replica_id)
return {"started": started}