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:
parent
900b682986
commit
d5df9ab662
2 changed files with 206 additions and 0 deletions
22
views.py
Normal file
22
views.py
Normal 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
184
views_api.py
Normal 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")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue