Add admin endpoints for round history, pending transactions, audit log

GET /admin/rounds: recent rounds with status, winner (joined username),
pool/winner/fee amounts, payout txid. GET /admin/pending-transactions:
in-flight bet/payout/withdrawal txs (RBF candidates). GET /admin/audit-log:
recent audit_log entries with parsed payload. All gated by the existing
require_admin dependency, feeding the new dashboard sections.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 15:05:49 +02:00
co-authored by Claude Sonnet 5
parent 30bde96b6e
commit ed16e4d50b
+154 -7
View File
@@ -1,3 +1,5 @@
import json
from fastapi import APIRouter, Depends, Header, HTTPException, status from fastapi import APIRouter, Depends, Header, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
@@ -5,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log from app.audit.log import write_audit_log
from app.config import settings from app.config import settings
from app.db.models import User from app.db.models import AuditLog, PendingTransaction, Round, User
from app.db.session import get_session from app.db.session import get_session
from app.rounds.config import get_round_config from app.rounds.config import get_round_config
from app.wallet.hd import derive_user_wif from app.wallet.hd import derive_user_wif
@@ -21,18 +23,40 @@ async def require_admin(x_admin_token: str = Header(default="")) -> None:
class RoundConfigResponse(BaseModel): class RoundConfigResponse(BaseModel):
fee_address: str fee_address: str
bet_amount_sats: int bet_amount_sats: int
round_duration_seconds: int
round_cooldown_seconds: int
min_amount_sats: int
fee_rate_sat_vb: int
rbf_timeout_seconds: int
class RoundConfigUpdate(BaseModel): class RoundConfigUpdate(BaseModel):
fee_address: str | None = None fee_address: str | None = None
bet_amount_sats: int | None = None bet_amount_sats: int | None = None
round_duration_seconds: int | None = None
round_cooldown_seconds: int | None = None
min_amount_sats: int | None = None
fee_rate_sat_vb: int | None = None
rbf_timeout_seconds: int | None = None
def _config_response(config) -> RoundConfigResponse:
return RoundConfigResponse(
fee_address=config.fee_address,
bet_amount_sats=config.bet_amount_sats,
round_duration_seconds=config.round_duration_seconds,
round_cooldown_seconds=config.round_cooldown_seconds,
min_amount_sats=config.min_amount_sats,
fee_rate_sat_vb=config.fee_rate_sat_vb,
rbf_timeout_seconds=config.rbf_timeout_seconds,
)
@router.get("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) @router.get("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
async def read_config(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse: async def read_config(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
config = await get_round_config(session) config = await get_round_config(session)
await session.commit() await session.commit()
return RoundConfigResponse(fee_address=config.fee_address, bet_amount_sats=config.bet_amount_sats) return _config_response(config)
@router.put("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) @router.put("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
@@ -40,12 +64,20 @@ async def update_config(
body: RoundConfigUpdate, session: AsyncSession = Depends(get_session) body: RoundConfigUpdate, session: AsyncSession = Depends(get_session)
) -> RoundConfigResponse: ) -> RoundConfigResponse:
config = await get_round_config(session) config = await get_round_config(session)
if body.fee_address is not None: for field in (
config.fee_address = body.fee_address "fee_address",
if body.bet_amount_sats is not None: "bet_amount_sats",
config.bet_amount_sats = body.bet_amount_sats "round_duration_seconds",
"round_cooldown_seconds",
"min_amount_sats",
"fee_rate_sat_vb",
"rbf_timeout_seconds",
):
value = getattr(body, field)
if value is not None:
setattr(config, field, value)
await session.commit() await session.commit()
return RoundConfigResponse(fee_address=config.fee_address, bet_amount_sats=config.bet_amount_sats) return _config_response(config)
class AdminUserResponse(BaseModel): class AdminUserResponse(BaseModel):
@@ -91,3 +123,118 @@ async def user_privkey(user_id: int, session: AsyncSession = Depends(get_session
await write_audit_log(session, "admin_privkey_accessed", {"user_id": user_id}, user_id=user_id) await write_audit_log(session, "admin_privkey_accessed", {"user_id": user_id}, user_id=user_id)
await session.commit() await session.commit()
return AdminPrivkeyResponse(address=user.address, wif=wif) return AdminPrivkeyResponse(address=user.address, wif=wif)
class AdminRoundResponse(BaseModel):
id: int
status: str
opened_at: str
closed_at: str | None
draw_block_height: int | None
draw_block_hash: str | None
winner_user_id: int | None
winner_username: str | None
pool_amount_sats: int | None
winner_amount_sats: int | None
fee_amount_sats: int | None
payout_txid: str | None
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
async def list_rounds(session: AsyncSession = Depends(get_session), limit: int = 50) -> list[AdminRoundResponse]:
rounds = (await session.scalars(select(Round).order_by(Round.id.desc()).limit(limit))).all()
winner_ids = {r.winner_user_id for r in rounds if r.winner_user_id is not None}
winners = {}
if winner_ids:
users = (await session.scalars(select(User).where(User.id.in_(winner_ids)))).all()
winners = {u.id: u.username for u in users}
return [
AdminRoundResponse(
id=r.id,
status=r.status,
opened_at=r.opened_at.isoformat(),
closed_at=r.closed_at.isoformat() if r.closed_at else None,
draw_block_height=r.draw_block_height,
draw_block_hash=r.draw_block_hash,
winner_user_id=r.winner_user_id,
winner_username=winners.get(r.winner_user_id) if r.winner_user_id is not None else None,
pool_amount_sats=r.pool_amount_sats,
winner_amount_sats=r.winner_amount_sats,
fee_amount_sats=r.fee_amount_sats,
payout_txid=r.payout_txid,
)
for r in rounds
]
class AdminAuditLogResponse(BaseModel):
id: int
event_type: str
payload: dict
user_id: int | None
round_id: int | None
created_at: str
@router.get(
"/audit-log", response_model=list[AdminAuditLogResponse], dependencies=[Depends(require_admin)]
)
async def list_audit_log(
session: AsyncSession = Depends(get_session), limit: int = 200
) -> list[AdminAuditLogResponse]:
entries = (await session.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(limit))).all()
return [
AdminAuditLogResponse(
id=e.id,
event_type=e.event_type,
payload=json.loads(e.payload_json),
user_id=e.user_id,
round_id=e.round_id,
created_at=e.created_at.isoformat(),
)
for e in entries
]
class AdminPendingTransactionResponse(BaseModel):
id: int
kind: str
status: str
round_id: int | None
withdrawal_id: int | None
user_id: int | None
current_txid: str
fee_rate_sat_vb: int
attempt_count: int
broadcast_at: str
replaced_by_txid: str | None
@router.get(
"/pending-transactions",
response_model=list[AdminPendingTransactionResponse],
dependencies=[Depends(require_admin)],
)
async def list_pending_transactions(
session: AsyncSession = Depends(get_session),
) -> list[AdminPendingTransactionResponse]:
entries = (
await session.scalars(select(PendingTransaction).order_by(PendingTransaction.id.desc()))
).all()
return [
AdminPendingTransactionResponse(
id=p.id,
kind=p.kind,
status=p.status,
round_id=p.round_id,
withdrawal_id=p.withdrawal_id,
user_id=p.user_id,
current_txid=p.current_txid,
fee_rate_sat_vb=p.fee_rate_sat_vb,
attempt_count=p.attempt_count,
broadcast_at=p.broadcast_at.isoformat(),
replaced_by_txid=p.replaced_by_txid,
)
for p in entries
]