chatelet/views_api.py
Padreug 0631edf8a2 feat: public guest room discovery endpoints (slice 1 for webapp #141)
GET /public/rooms + /public/rooms/{id} — no auth, active rooms only,
operator-private fields stripped. The webapp guest UI needs these because
the existing GET /rooms is admin-scoped; availability + booking POST are
already public. Reuses the public_room_dict strip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-20 01:39:39 +02:00

245 lines
7.9 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
from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import require_admin_key, require_invoice_key
from . import crud, services
from .models import (
AvailabilityQuery,
AvailabilityResult,
Block,
Booking,
BookingQuote,
BookingRequestData,
ChateletSettings,
CreateBlockData,
CreateRoomData,
Room,
RoomStatus,
public_room_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)
# --- public guest discovery (no auth) --------------------------------------
@chatelet_api_router.get("/api/v1/public/rooms")
async def api_public_rooms() -> list[dict]:
"""Active rooms for guest browsing — operator-private fields stripped."""
return [
public_room_dict(r)
for r in await crud.get_rooms()
if r.status == RoomStatus.active
]
@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 public_room_dict(room)
# --- 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/{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