feat: REST API + operator admin route

views_api.py exposes the booking flow over HTTP — availability check,
guest booking request (check-then-hold with canonical amount_sat), room
publish, blocks. REST and Nostr are two doors into the same crud flow;
FX + invoice creation marked TODO. views.py serves the operator page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
This commit is contained in:
Padreug 2026-07-19 00:16:57 +02:00
commit d5df9ab662
2 changed files with 206 additions and 0 deletions

22
views.py Normal file
View file

@ -0,0 +1,22 @@
"""Frontend routes — serves the operator admin page. SKETCH: single index
template; the guest-facing booking UI is expected to live in the webapp
(standalone app pattern), talking to this extension over Nostr/REST."""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
chatelet_generic_router = APIRouter()
def chatelet_renderer():
return template_renderer(["chatelet/templates"])
@chatelet_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return chatelet_renderer().TemplateResponse(
"chatelet/index.html", {"request": request, "user": user.json()}
)

184
views_api.py Normal file
View file

@ -0,0 +1,184 @@
"""Chatelet REST API.
The REST surface and the Nostr surface (nostr/service.py) are two doors
into the SAME booking flow both funnel through crud + the helpers here so
availability arbitration and quoting live in exactly one place. Per the
aiolabs long-term direction (webapp<->lnbits over Nostr), REST is the
transitional door; don't grow booking logic that only the HTTP path knows.
SKETCH: handlers show the intended flow and auth scopes; the pricing/FX and
invoice-creation calls are marked TODO where they'd touch core LNbits.
"""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import require_admin_key, require_invoice_key
from lnbits.helpers import urlsafe_short_hash
from . import crud
from .models import (
AvailabilityQuery,
AvailabilityResult,
Booking,
BookingRequestData,
BookingStatus,
CreateBlockData,
CreateRoomData,
Room,
)
from .nostr import service as nostr
chatelet_api_router = APIRouter()
# --- rooms (operator; admin-key scoped to own wallet) ----------------------
@chatelet_api_router.post("/api/v1/rooms", status_code=201)
async def api_create_room(
data: CreateRoomData, key: WalletTypeInfo = Depends(require_admin_key)
) -> Room:
data.wallet = data.wallet or key.wallet.id
return await crud.create_room(data)
@chatelet_api_router.get("/api/v1/rooms")
async def api_list_rooms() -> list[Room]:
return await crud.get_rooms()
@chatelet_api_router.post("/api/v1/rooms/{room_id}/publish")
async def api_publish_room(
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
) -> Room:
room = await crud.get_room(room_id)
if not room:
raise HTTPException(404, "Room not found")
room.status = room.status.active
room.listing_event_id = await nostr.publish_listing(room) or room.listing_event_id
return await crud.update_room(room)
# --- availability (public read) --------------------------------------------
@chatelet_api_router.post("/api/v1/availability")
async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult:
room = await crud.get_room(q.room_id)
if not room:
raise HTTPException(404, "Room not found")
nights = crud.nights_between(q.check_in, q.check_out)
if nights < 1:
raise HTTPException(400, "check_out must be after check_in")
available = await crud.is_available(q.room_id, q.check_in, q.check_out)
quote_sat = quote_fiat = None
if available:
quote_fiat = round(room.price_amount * nights, 2)
quote_sat = await _to_sats(quote_fiat, room.price_currency)
return AvailabilityResult(
room_id=q.room_id,
check_in=q.check_in,
check_out=q.check_out,
available=available,
nights=nights,
quote_sat=quote_sat,
quote_fiat=quote_fiat,
currency=room.price_currency,
)
# --- booking (public write; guest-initiated) -------------------------------
@chatelet_api_router.post("/api/v1/bookings", status_code=201)
async def api_request_booking(data: BookingRequestData) -> Booking:
"""Guest requests a stay. Check-then-hold is the lock: if available we
write a `held` booking with the canonical amount_sat and an expiry, then
(TODO) create the deposit invoice. The Nostr path calls the same steps."""
room = await crud.get_room(data.room_id)
if not room or room.status != room.status.active:
raise HTTPException(404, "Room not available")
nights = crud.nights_between(data.check_in, data.check_out)
if nights < room.min_nights:
raise HTTPException(400, f"Minimum stay is {room.min_nights} night(s)")
if data.num_guests > room.max_guests:
raise HTTPException(400, f"Max {room.max_guests} guests")
# TODO(concurrency): wrap is_available + create_booking in a per-room
# lock / DB transaction so two requests can't both pass the read.
if not await crud.is_available(data.room_id, data.check_in, data.check_out):
raise HTTPException(409, "Those dates are no longer available")
settings = await crud.get_or_create_settings()
price_fiat = round(room.price_amount * nights, 2)
amount_sat = await _to_sats(price_fiat, room.price_currency) # canonical
deposit_sat = amount_sat * settings.deposit_percent // 100
booking = Booking(
id=urlsafe_short_hash()[:10],
room_id=room.id,
guest_pubkey=data.guest_pubkey,
guest_contact=data.guest_contact,
check_in=data.check_in,
check_out=data.check_out,
nights=nights,
num_guests=data.num_guests,
currency=room.price_currency,
price_fiat=price_fiat,
amount_sat=amount_sat,
deposit_sat=deposit_sat,
status=BookingStatus.held,
expires_at=datetime.now(timezone.utc)
+ timedelta(minutes=settings.default_hold_minutes),
)
await crud.create_booking(booking)
# TODO(payment): create_invoice(wallet_id=room.wallet, amount=deposit_sat,
# extra={"tag": "chatelet", "booking_id": booking.id}); store payment_hash;
# set status=awaiting_payment; DM the bolt11 to the guest (NIP-17).
return booking
@chatelet_api_router.get("/api/v1/bookings/{booking_id}")
async def api_get_booking(
booking_id: str, key: WalletTypeInfo = Depends(require_invoice_key)
) -> Booking:
booking = await crud.get_booking(booking_id)
if not booking:
raise HTTPException(404, "Booking not found")
return booking
# --- blocks (operator) -----------------------------------------------------
@chatelet_api_router.post("/api/v1/blocks", status_code=201)
async def api_create_block(
data: CreateBlockData, key: WalletTypeInfo = Depends(require_admin_key)
):
block = await crud.create_block(data)
room = await crud.get_room(data.room_id)
if room:
await nostr.publish_block_calendar(
room, block.start_date, block.end_date, block.id
)
return block
# --- helpers ---------------------------------------------------------------
async def _to_sats(amount: float, currency: str) -> int:
"""Fiat/currency -> sats. Canonical conversion happens HERE, once, at
quote/hold time; the result is stored as amount_sat and never recomputed
downstream.
TODO(fx): if currency in ('sat','sats') return int(amount); else call
lnbits.utils.exchange_rates.fiat_amount_as_satoshis(amount, currency).
"""
if currency.lower() in ("sat", "sats"):
return int(amount)
raise NotImplementedError("wire lnbits fiat_amount_as_satoshis")