From 7226b8289dc47d97a1d27f215003c6cdec4b6bb6 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 14 May 2026 15:35:15 +0200 Subject: [PATCH] feat(v2): client CRUD + balance summary endpoints (P3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 6 operator-scoped LP management endpoints: POST /api/v1/dca/clients — register LP at a machine GET /api/v1/dca/clients — operator's LPs (all) GET /api/v1/dca/clients?machine_id=X — scoped to one machine GET /api/v1/dca/clients/{id} — single LP PUT /api/v1/dca/clients/{id} — update mode/autoforward/etc DELETE /api/v1/dca/clients/{id} — delete GET /api/v1/dca/clients/{id}/balance — fiat balance summary Ownership transitively checked via the LP's machine — operators can only see/modify LPs at machines they own. New _machine_owned_by and _client_owned_by helpers consolidate the 404-not-403 ownership pattern. Refs: aiolabs/satmachineadmin#9 Co-Authored-By: Claude Opus 4.7 (1M context) --- views_api.py | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/views_api.py b/views_api.py index 8840425..62a87bc 100644 --- a/views_api.py +++ b/views_api.py @@ -12,8 +12,14 @@ from lnbits.core.models import User from lnbits.decorators import check_super_user, check_user_exists from .crud import ( + create_dca_client, create_machine, + delete_dca_client, delete_machine, + get_client_balance_summary, + get_dca_client, + get_dca_clients_for_machine, + get_dca_clients_for_operator, get_machine, get_machines_for_operator, get_payments_for_operator, @@ -21,15 +27,20 @@ from .crud import ( get_settlements_for_machine, get_settlements_for_operator, get_super_config, + update_dca_client, update_machine, update_super_config, ) from .models import ( + ClientBalanceSummary, + CreateDcaClientData, CreateMachineData, + DcaClient, DcaPayment, DcaSettlement, Machine, SuperConfig, + UpdateDcaClientData, UpdateMachineData, UpdateSuperConfigData, ) @@ -99,6 +110,107 @@ async def api_delete_machine( await delete_machine(machine_id) +# ============================================================================= +# DCA Clients (LPs) — scoped per (machine, user). +# ============================================================================= + + +async def _machine_owned_by(machine_id: str, user_id: str) -> Machine: + """Lookup-with-ownership guard. 404 (not 403) so operators can't probe + for other operators' machines.""" + machine = await get_machine(machine_id) + if machine is None or machine.operator_user_id != user_id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") + return machine + + +async def _client_owned_by(client_id: str, user_id: str) -> DcaClient: + """Lookup-with-ownership guard for an LP record; ownership is checked + transitively via the client's machine. 404 if either doesn't match.""" + client = await get_dca_client(client_id) + if client is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Client not found") + machine = await get_machine(client.machine_id) + if machine is None or machine.operator_user_id != user_id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Client not found") + return client + + +@satmachineadmin_api_router.post( + "/api/v1/dca/clients", response_model=DcaClient +) +async def api_create_client( + data: CreateDcaClientData, user: User = Depends(check_user_exists) +) -> DcaClient: + # Operator can only register LPs on machines they own. + await _machine_owned_by(data.machine_id, user.id) + return await create_dca_client(data) + + +@satmachineadmin_api_router.get( + "/api/v1/dca/clients", response_model=list[DcaClient] +) +async def api_list_clients( + machine_id: str | None = None, + user: User = Depends(check_user_exists), +) -> list[DcaClient]: + """List the operator's LPs. Without ?machine_id, returns all LPs across + the operator's fleet. With ?machine_id, scoped to that machine (with + ownership check).""" + if machine_id is None: + return await get_dca_clients_for_operator(user.id) + await _machine_owned_by(machine_id, user.id) + return await get_dca_clients_for_machine(machine_id) + + +@satmachineadmin_api_router.get( + "/api/v1/dca/clients/{client_id}", response_model=DcaClient +) +async def api_get_client( + client_id: str, user: User = Depends(check_user_exists) +) -> DcaClient: + return await _client_owned_by(client_id, user.id) + + +@satmachineadmin_api_router.put( + "/api/v1/dca/clients/{client_id}", response_model=DcaClient +) +async def api_update_client( + client_id: str, + data: UpdateDcaClientData, + user: User = Depends(check_user_exists), +) -> DcaClient: + await _client_owned_by(client_id, user.id) + updated = await update_dca_client(client_id, data) + if updated is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Client not found") + return updated + + +@satmachineadmin_api_router.delete( + "/api/v1/dca/clients/{client_id}", status_code=HTTPStatus.NO_CONTENT +) +async def api_delete_client( + client_id: str, user: User = Depends(check_user_exists) +) -> None: + await _client_owned_by(client_id, user.id) + await delete_dca_client(client_id) + + +@satmachineadmin_api_router.get( + "/api/v1/dca/clients/{client_id}/balance", + response_model=ClientBalanceSummary, +) +async def api_get_client_balance( + client_id: str, user: User = Depends(check_user_exists) +) -> ClientBalanceSummary: + await _client_owned_by(client_id, user.id) + summary = await get_client_balance_summary(client_id) + if summary is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Client not found") + return summary + + # ============================================================================= # Settlements (read-only at this phase; landing happens in tasks.py) # =============================================================================