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:
196
app/api/replicas.py
Normal file
196
app/api/replicas.py
Normal file
@@ -0,0 +1,196 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user