feat: operator admin UI + backing endpoints (v0.2.0) #16
1 changed files with 136 additions and 27 deletions
feat: operator admin REST endpoints (rooms/blocks/bookings/settings)
Adds the HTTP surface the admin UI needs, all admin-key + ownership-scoped: - rooms: list (filtered to caller's wallet), update (PUT), delete, publish / unpublish (with ownership checks; publish/unpublish flip status + sync the relay listing). - per-room: GET bookings, GET blocks. - blocks: create (ownership-checked) + delete. - settings: GET + PUT (merges only the editable fields). _owned_room centralizes the 404/403 ownership guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
commit
f715d728ef
163
views_api.py
163
views_api.py
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
The REST surface and the Nostr-transport surface (transport_rpcs.py) are two
|
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
|
doors into the SAME booking flow — both delegate to services.py so
|
||||||
availability arbitration + quoting live in one place. Per the aiolabs
|
availability arbitration + quoting live in one place. The operator admin
|
||||||
long-term direction (webapp<->lnbits over Nostr), REST is the transitional
|
endpoints (room/block CRUD, settings) are HTTP-only and back the admin UI;
|
||||||
door; keep new booking logic in services.py, not here.
|
the guest-facing surface (availability, booking) is what also rides the RPC.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
@ -15,17 +15,35 @@ from . import crud, services
|
||||||
from .models import (
|
from .models import (
|
||||||
AvailabilityQuery,
|
AvailabilityQuery,
|
||||||
AvailabilityResult,
|
AvailabilityResult,
|
||||||
|
Block,
|
||||||
Booking,
|
Booking,
|
||||||
BookingQuote,
|
BookingQuote,
|
||||||
BookingRequestData,
|
BookingRequestData,
|
||||||
|
ChateletSettings,
|
||||||
CreateBlockData,
|
CreateBlockData,
|
||||||
CreateRoomData,
|
CreateRoomData,
|
||||||
Room,
|
Room,
|
||||||
|
RoomStatus,
|
||||||
)
|
)
|
||||||
from .nostr import service as nostr
|
from .nostr import service as nostr
|
||||||
|
|
||||||
chatelet_api_router = APIRouter()
|
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:
|
def _to_http(exc: ValueError) -> HTTPException:
|
||||||
"""Map a services-layer ValueError subclass to an HTTP status."""
|
"""Map a services-layer ValueError subclass to an HTTP status."""
|
||||||
|
|
@ -36,34 +54,141 @@ def _to_http(exc: ValueError) -> HTTPException:
|
||||||
return HTTPException(400, 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) ----------------------
|
# --- 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)
|
@chatelet_api_router.post("/api/v1/rooms", status_code=201)
|
||||||
async def api_create_room(
|
async def api_create_room(
|
||||||
data: CreateRoomData, key: WalletTypeInfo = Depends(require_admin_key)
|
data: CreateRoomData, key: WalletTypeInfo = Depends(require_admin_key)
|
||||||
) -> Room:
|
) -> Room:
|
||||||
data.wallet = data.wallet or key.wallet.id
|
data.wallet = key.wallet.id # rooms are owned by the calling wallet
|
||||||
return await crud.create_room(data)
|
return await crud.create_room(data)
|
||||||
|
|
||||||
|
|
||||||
@chatelet_api_router.get("/api/v1/rooms")
|
@chatelet_api_router.put("/api/v1/rooms/{room_id}")
|
||||||
async def api_list_rooms() -> list[Room]:
|
async def api_update_room(
|
||||||
return await crud.get_rooms()
|
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")
|
@chatelet_api_router.post("/api/v1/rooms/{room_id}/publish")
|
||||||
async def api_publish_room(
|
async def api_publish_room(
|
||||||
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
|
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
|
||||||
) -> Room:
|
) -> Room:
|
||||||
room = await crud.get_room(room_id)
|
room = await _owned_room(room_id, key)
|
||||||
if not room:
|
room.status = RoomStatus.active
|
||||||
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
|
room.listing_event_id = await nostr.publish_listing(room) or room.listing_event_id
|
||||||
return await crud.update_room(room)
|
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)
|
||||||
|
|
||||||
|
|
||||||
# --- availability (public read) --------------------------------------------
|
# --- availability (public read) --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -96,19 +221,3 @@ async def api_get_booking(
|
||||||
if not booking:
|
if not booking:
|
||||||
raise HTTPException(404, "Booking not found")
|
raise HTTPException(404, "Booking not found")
|
||||||
return booking
|
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
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue