GET /api/v1/bookings/mine (LNbits account auth; identity = the account's
Nostr pubkey, the same value the booking request carried) and RPC twin
chatelet_booking_list_mine (scoped by the signed sender_pubkey). Rows come
back newest check-in first via the m003 guest index, as guest_booking_dict:
the guest's own contact and counts, minus the Lightning/Nostr plumbing.
Declared ahead of /bookings/{booking_id} so 'mine' is not read as an id.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
318 lines
11 KiB
Python
318 lines
11 KiB
Python
"""Chatelet REST API.
|
|
|
|
The REST surface and the Nostr-transport surface (transport_rpcs.py) are two
|
|
doors into the SAME booking flow — both delegate to services.py so
|
|
availability arbitration + quoting live in one place. The operator admin
|
|
endpoints (room/block CRUD, settings) are HTTP-only and back the admin UI;
|
|
the guest-facing surface (availability, booking) is what also rides the RPC.
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from lnbits.core.models import User, WalletTypeInfo
|
|
from lnbits.decorators import check_user_exists, require_admin_key, require_invoice_key
|
|
|
|
from . import crud, services
|
|
from .models import (
|
|
AvailabilityQuery,
|
|
AvailabilityResult,
|
|
Block,
|
|
Booking,
|
|
BookingQuote,
|
|
BookingRequestData,
|
|
ChateletSettings,
|
|
CreateBlockData,
|
|
CreateRoomData,
|
|
OperatorSettings,
|
|
Room,
|
|
RoomStatus,
|
|
UnavailableRanges,
|
|
UpdateOperatorSettings,
|
|
public_booking_dict,
|
|
)
|
|
from .nostr import service as nostr
|
|
|
|
chatelet_api_router = APIRouter()
|
|
|
|
# Fields patchable via PUT /rooms/{id}. Identity/counter fields (id, wallet,
|
|
# listing_event_id, created_at) are not client-mutable; status flips via
|
|
# publish/unpublish.
|
|
_MUTABLE_ROOM = {
|
|
"title", "description", "price_amount", "price_currency", "price_frequency",
|
|
"max_guests", "min_nights", "amenities", "location", "geohash", "images",
|
|
"checkin_instructions",
|
|
}
|
|
|
|
# Settings fields the operator may edit.
|
|
_EDITABLE_SETTINGS = (
|
|
"operator_id", "relays", "default_hold_minutes", "deposit_percent",
|
|
"checkin_time", "checkout_time", "cancellation_policy", "publish_availability",
|
|
)
|
|
|
|
|
|
def _to_http(exc: ValueError) -> HTTPException:
|
|
"""Map a services-layer ValueError subclass to an HTTP status."""
|
|
if isinstance(exc, services.NotFound):
|
|
return HTTPException(404, str(exc))
|
|
if isinstance(exc, services.Unavailable):
|
|
return HTTPException(409, str(exc))
|
|
return HTTPException(400, str(exc))
|
|
|
|
|
|
async def _owned_room(room_id: str, key: WalletTypeInfo) -> Room:
|
|
room = await crud.get_room(room_id)
|
|
if not room:
|
|
raise HTTPException(404, "Room not found")
|
|
if room.wallet != key.wallet.id:
|
|
raise HTTPException(403, "Room does not belong to this wallet")
|
|
return room
|
|
|
|
|
|
# --- rooms (operator; admin-key scoped to own wallet) ----------------------
|
|
|
|
|
|
@chatelet_api_router.get("/api/v1/rooms")
|
|
async def api_list_rooms(
|
|
key: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> list[Room]:
|
|
return [r for r in await crud.get_rooms() if r.wallet == key.wallet.id]
|
|
|
|
|
|
@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 = key.wallet.id # rooms are owned by the calling wallet
|
|
return await crud.create_room(data)
|
|
|
|
|
|
@chatelet_api_router.put("/api/v1/rooms/{room_id}")
|
|
async def api_update_room(
|
|
room_id: str,
|
|
data: CreateRoomData,
|
|
key: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> Room:
|
|
room = await _owned_room(room_id, key)
|
|
for field in _MUTABLE_ROOM:
|
|
setattr(room, field, getattr(data, field))
|
|
room = await crud.update_room(room)
|
|
if room.status == RoomStatus.active:
|
|
# keep the published listing in sync with the edit
|
|
room.listing_event_id = (
|
|
await nostr.publish_listing(room) or room.listing_event_id
|
|
)
|
|
room = await crud.update_room(room)
|
|
return room
|
|
|
|
|
|
@chatelet_api_router.delete("/api/v1/rooms/{room_id}")
|
|
async def api_delete_room(
|
|
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
|
|
) -> dict:
|
|
await _owned_room(room_id, key)
|
|
await crud.delete_room(room_id)
|
|
return {"deleted": True}
|
|
|
|
|
|
@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 _owned_room(room_id, key)
|
|
room.status = RoomStatus.active
|
|
room.listing_event_id = await nostr.publish_listing(room) or room.listing_event_id
|
|
return await crud.update_room(room)
|
|
|
|
|
|
@chatelet_api_router.post("/api/v1/rooms/{room_id}/unpublish")
|
|
async def api_unpublish_room(
|
|
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
|
|
) -> Room:
|
|
room = await _owned_room(room_id, key)
|
|
room.status = RoomStatus.inactive
|
|
return await crud.update_room(room)
|
|
|
|
|
|
@chatelet_api_router.get("/api/v1/rooms/{room_id}/bookings")
|
|
async def api_room_bookings(
|
|
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
|
|
) -> list[Booking]:
|
|
await _owned_room(room_id, key)
|
|
return await crud.get_bookings_for_room(room_id)
|
|
|
|
|
|
@chatelet_api_router.get("/api/v1/rooms/{room_id}/blocks")
|
|
async def api_room_blocks(
|
|
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
|
|
) -> list[Block]:
|
|
await _owned_room(room_id, key)
|
|
return await crud.get_blocks_for_room(room_id)
|
|
|
|
|
|
# --- 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 _owned_room(data.room_id, 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
|
|
|
|
|
|
@chatelet_api_router.delete("/api/v1/blocks/{block_id}")
|
|
async def api_delete_block(
|
|
block_id: str, key: WalletTypeInfo = Depends(require_admin_key)
|
|
) -> dict:
|
|
await crud.delete_block(block_id)
|
|
return {"deleted": True}
|
|
|
|
|
|
# --- settings (operator) ---------------------------------------------------
|
|
|
|
|
|
@chatelet_api_router.get("/api/v1/settings")
|
|
async def api_get_settings(
|
|
key: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> ChateletSettings:
|
|
return await crud.get_or_create_settings()
|
|
|
|
|
|
@chatelet_api_router.put("/api/v1/settings")
|
|
async def api_update_settings(
|
|
data: ChateletSettings, key: WalletTypeInfo = Depends(require_admin_key)
|
|
) -> ChateletSettings:
|
|
settings = await crud.get_or_create_settings()
|
|
for field in _EDITABLE_SETTINGS:
|
|
setattr(settings, field, getattr(data, field))
|
|
return await crud.update_settings(settings)
|
|
|
|
|
|
# --- operator settings (per LNbits user; admin key → wallet → user) ----------
|
|
|
|
|
|
def _with_providers(ops: OperatorSettings) -> dict:
|
|
"""The row plus what core would actually let this user charge with, so
|
|
the admin UI can explain a card toggle that has no provider behind it."""
|
|
d = ops.dict()
|
|
d["available_fiat_providers"] = services.fiat_providers_for_user(ops.user_id)
|
|
return d
|
|
|
|
|
|
@chatelet_api_router.get("/api/v1/operator")
|
|
async def api_get_operator_settings(
|
|
key: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> dict:
|
|
ops = await crud.get_or_create_operator_settings(key.wallet.user)
|
|
return _with_providers(ops)
|
|
|
|
|
|
@chatelet_api_router.put("/api/v1/operator")
|
|
async def api_update_operator_settings(
|
|
data: UpdateOperatorSettings, key: WalletTypeInfo = Depends(require_admin_key)
|
|
) -> dict:
|
|
ops = await crud.get_or_create_operator_settings(key.wallet.user)
|
|
for field in UpdateOperatorSettings.__fields__:
|
|
setattr(ops, field, getattr(data, field))
|
|
ops = await crud.update_operator_settings(ops)
|
|
# Rails/house rules ride on the public listing — refresh the owner's rooms.
|
|
for room in await crud.get_rooms():
|
|
if room.status == RoomStatus.active:
|
|
owner = await services.room_owner_id(room)
|
|
if owner == ops.user_id:
|
|
await nostr.publish_listing(room)
|
|
return _with_providers(ops)
|
|
|
|
|
|
# --- public guest discovery (no auth) --------------------------------------
|
|
|
|
|
|
@chatelet_api_router.get("/api/v1/public/rooms")
|
|
async def api_public_rooms() -> list[dict]:
|
|
"""Active rooms for guests — wallet + check-in instructions stripped,
|
|
owner's house rules + accepted rails attached."""
|
|
rooms = [r for r in await crud.get_rooms() if r.status == RoomStatus.active]
|
|
return await services.public_room_views(rooms)
|
|
|
|
|
|
@chatelet_api_router.get("/api/v1/public/rooms/{room_id}")
|
|
async def api_public_room(room_id: str) -> dict:
|
|
room = await crud.get_room(room_id)
|
|
if not room or room.status != RoomStatus.active:
|
|
raise HTTPException(404, "Room not available")
|
|
return await services.public_room_view(room)
|
|
|
|
|
|
@chatelet_api_router.get("/api/v1/public/rooms/{room_id}/unavailable")
|
|
async def api_public_room_unavailable(
|
|
room_id: str,
|
|
start: str | None = Query(None, description="YYYY-MM-DD, default today"),
|
|
end: str | None = Query(None, description="YYYY-MM-DD exclusive, default +365d"),
|
|
) -> UnavailableRanges:
|
|
"""Nights a guest cannot book, merged and anonymous — what a calendar
|
|
greys out. Keyless, like the room read it hangs off."""
|
|
try:
|
|
return await services.get_unavailable_ranges(room_id, start, end)
|
|
except ValueError as exc:
|
|
raise _to_http(exc) from exc
|
|
|
|
|
|
# --- availability (public read) --------------------------------------------
|
|
|
|
|
|
@chatelet_api_router.post("/api/v1/availability")
|
|
async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult:
|
|
try:
|
|
return await services.get_availability(q.room_id, q.check_in, q.check_out)
|
|
except ValueError as exc:
|
|
raise _to_http(exc) from exc
|
|
|
|
|
|
# --- booking (public write; guest-initiated) -------------------------------
|
|
|
|
|
|
@chatelet_api_router.post("/api/v1/bookings", status_code=201)
|
|
async def api_request_booking(data: BookingRequestData) -> BookingQuote:
|
|
try:
|
|
return await services.request_booking(data)
|
|
except services.BookingError as exc:
|
|
raise HTTPException(502, str(exc)) from exc
|
|
except ValueError as exc:
|
|
raise _to_http(exc) from exc
|
|
|
|
|
|
@chatelet_api_router.get("/api/v1/bookings/mine")
|
|
async def api_my_bookings(user: User = Depends(check_user_exists)) -> list[dict]:
|
|
"""The signed-in guest's own stays. Identity is the LNbits account's Nostr
|
|
pubkey — the same value the booking request carried as guest_pubkey.
|
|
Declared before /bookings/{booking_id} so "mine" is not read as an id."""
|
|
if not user.pubkey:
|
|
raise HTTPException(409, "This account has no Nostr pubkey")
|
|
return await services.list_guest_bookings(user.pubkey)
|
|
|
|
|
|
@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
|
|
|
|
|
|
@chatelet_api_router.get("/api/v1/public/bookings/{booking_id}")
|
|
async def api_public_booking(booking_id: str) -> dict:
|
|
"""Guest read-back of their own booking (keyless). The guest can't
|
|
subscribe to the operator's wallet, so this is how a client waits for
|
|
`awaiting_payment` -> `confirmed` (or sees `expired` / `declined`)
|
|
without a key — the HTTP twin of the RPC door's chatelet_booking_get."""
|
|
booking = await crud.get_booking(booking_id)
|
|
if not booking:
|
|
raise HTTPException(404, "Booking not found")
|
|
return public_booking_dict(booking)
|