diff --git a/views_api.py b/views_api.py index fd2d430..13dd53c 100644 --- a/views_api.py +++ b/views_api.py @@ -1,20 +1,190 @@ -# Satoshi Machine v2 — API placeholder. +# Satoshi Machine v2 — operator API surface (P1b). # -# The v1 super-only Lamassu endpoints have been removed. The v2 operator- -# scoped surface (machines / clients / deposits / settlements / commission -# splits / partial-tx / balance-settle / super platform-fee) lands in P1+. -# See plan section "Critical files to modify". -# -# This stub keeps __init__.py importable and surfaces a clear 503 on every -# v1 route so existing clients get a precise error instead of a silent 404. +# All endpoints are operator-scoped via check_user_exists. Every query +# filters by the authenticated user's id so two operators on the same +# LNbits instance can never see each other's machines, settlements, or +# clients. The super-only platform-fee write endpoint lands in P2. from http import HTTPStatus -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from .crud import ( + create_machine, + delete_machine, + get_machine, + get_machines_for_operator, + get_payments_for_operator, + get_settlement, + get_settlements_for_machine, + get_settlements_for_operator, + get_super_config, + update_machine, +) +from .models import ( + CreateMachineData, + DcaPayment, + DcaSettlement, + Machine, + SuperConfig, + UpdateMachineData, +) satmachineadmin_api_router = APIRouter() +# ============================================================================= +# Machines +# ============================================================================= + + +@satmachineadmin_api_router.post("/api/v1/dca/machines", response_model=Machine) +async def api_create_machine( + data: CreateMachineData, user: User = Depends(check_user_exists) +) -> Machine: + return await create_machine(user.id, data) + + +@satmachineadmin_api_router.get( + "/api/v1/dca/machines", response_model=list[Machine] +) +async def api_list_machines( + user: User = Depends(check_user_exists), +) -> list[Machine]: + return await get_machines_for_operator(user.id) + + +@satmachineadmin_api_router.get( + "/api/v1/dca/machines/{machine_id}", response_model=Machine +) +async def api_get_machine( + machine_id: str, user: User = Depends(check_user_exists) +) -> Machine: + 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 + + +@satmachineadmin_api_router.put( + "/api/v1/dca/machines/{machine_id}", response_model=Machine +) +async def api_update_machine( + machine_id: str, + data: UpdateMachineData, + user: User = Depends(check_user_exists), +) -> Machine: + 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") + updated = await update_machine(machine_id, data) + if updated is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") + return updated + + +@satmachineadmin_api_router.delete( + "/api/v1/dca/machines/{machine_id}", status_code=HTTPStatus.NO_CONTENT +) +async def api_delete_machine( + machine_id: str, user: User = Depends(check_user_exists) +) -> None: + 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") + await delete_machine(machine_id) + + +# ============================================================================= +# Settlements (read-only at this phase; landing happens in tasks.py) +# ============================================================================= + + +@satmachineadmin_api_router.get( + "/api/v1/dca/settlements", response_model=list[DcaSettlement] +) +async def api_list_settlements( + user: User = Depends(check_user_exists), +) -> list[DcaSettlement]: + return await get_settlements_for_operator(user.id) + + +@satmachineadmin_api_router.get( + "/api/v1/dca/machines/{machine_id}/settlements", + response_model=list[DcaSettlement], +) +async def api_list_settlements_for_machine( + machine_id: str, user: User = Depends(check_user_exists) +) -> list[DcaSettlement]: + 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 await get_settlements_for_machine(machine_id) + + +@satmachineadmin_api_router.get( + "/api/v1/dca/settlements/{settlement_id}", response_model=DcaSettlement +) +async def api_get_settlement( + settlement_id: str, user: User = Depends(check_user_exists) +) -> DcaSettlement: + settlement = await get_settlement(settlement_id) + if settlement is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found") + machine = await get_machine(settlement.machine_id) + if machine is None or machine.operator_user_id != user.id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found") + return settlement + + +# ============================================================================= +# Payments (read-only — the leg-typed breakdown of distributions) +# ============================================================================= + + +@satmachineadmin_api_router.get( + "/api/v1/dca/payments", response_model=list[DcaPayment] +) +async def api_list_payments( + leg_type: str | None = None, + user: User = Depends(check_user_exists), +) -> list[DcaPayment]: + return await get_payments_for_operator(user.id, leg_type=leg_type) + + +# ============================================================================= +# Super config — read-only at this phase. Super-only write endpoint lands in P2. +# ============================================================================= + + +@satmachineadmin_api_router.get( + "/api/v1/dca/super-config", response_model=SuperConfig +) +async def api_get_super_config( + _user: User = Depends(check_user_exists), +) -> SuperConfig: + """Returns the platform-fee config so operators can display it as a + read-only line item in their UI. The fee is set by the LNbits super + instance-wide; operators see it but can't change it (write endpoint + protected by check_super_user, landing in P2).""" + config = await get_super_config() + if config is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, "Super config not initialised" + ) + return config + + +# ============================================================================= +# Catch-all stub for endpoints not yet implemented (clients, deposits, +# commission splits, partial-tx, balance-settle, super-config write). Each +# lands in a follow-up commit. The catch-all comes LAST so specific routes +# above take precedence. +# ============================================================================= + + @satmachineadmin_api_router.api_route( "/api/v1/dca/{full_path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"], @@ -22,7 +192,6 @@ satmachineadmin_api_router = APIRouter() async def v2_in_progress_stub(full_path: str) -> None: raise HTTPException( HTTPStatus.SERVICE_UNAVAILABLE, - f"satmachineadmin v2 API not yet implemented (path: /{full_path}). " - "The v1 Lamassu surface has been removed; per-operator endpoints " - "land in P1. See plan.", + f"satmachineadmin v2: /api/v1/dca/{full_path} not yet implemented " + "(landing in P2+). See aiolabs/satmachineadmin#9.", )