feat: operator settings, guest booking list, card checkout #22
17 changed files with 531 additions and 42 deletions
feat: per-operator settings — house rules + card acceptance (multi-tenant)
Chatelet is multi-tenant: any LNbits user can host rooms. What an operator decides for all their rooms now lives in chatelet.operator_settings, keyed by user id and created lazily (m003, which also indexes bookings by guest): check-in/out times, cancellation policy, and accept_fiat. Guests see it: the public room view (both doors) gains house_rules and payment_methods, and the kind:30402 listing carries payment_methods, checkin_time and checkout_time tags so a generic Nostr client can render the right pay buttons and rules without our RPC. The check-in DM reads the room owner's rules instead of the instance row. Card is offered only when the operator opted in, the room is fiat-priced, and LNbits core has a fiat provider for that user — resolved through settings.get_fiat_providers_for_user(owner), the one seam lnbits#67's per-user Stripe credentials will plug into; chatelet never sees creds. Operator endpoints: GET/PUT /api/v1/operator (admin key → wallet user) and RPC twins chatelet_operator_get/update (AUTH_WALLET); saving re-publishes the owner's active listings. Admin UI moves the house-rule inputs into a per-operator card with the card toggle and a provider hint. The old house-rule columns on settings stay for old rows but are no longer read. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
commit
9ab52f2c69
|
|
@ -68,6 +68,8 @@ def chatelet_start():
|
||||||
handle_block_create,
|
handle_block_create,
|
||||||
handle_booking_get,
|
handle_booking_get,
|
||||||
handle_booking_request,
|
handle_booking_request,
|
||||||
|
handle_operator_get,
|
||||||
|
handle_operator_update,
|
||||||
handle_room_create,
|
handle_room_create,
|
||||||
handle_room_get,
|
handle_room_get,
|
||||||
handle_room_list,
|
handle_room_list,
|
||||||
|
|
@ -84,6 +86,8 @@ def chatelet_start():
|
||||||
register_rpc("chatelet_room_publish", handle_room_publish, AUTH_WALLET)
|
register_rpc("chatelet_room_publish", handle_room_publish, AUTH_WALLET)
|
||||||
register_rpc("chatelet_block_create", handle_block_create, AUTH_WALLET)
|
register_rpc("chatelet_block_create", handle_block_create, AUTH_WALLET)
|
||||||
register_rpc("chatelet_room_list_mine", handle_room_list_mine, AUTH_ACCOUNT)
|
register_rpc("chatelet_room_list_mine", handle_room_list_mine, AUTH_ACCOUNT)
|
||||||
|
register_rpc("chatelet_operator_get", handle_operator_get, AUTH_WALLET)
|
||||||
|
register_rpc("chatelet_operator_update", handle_operator_update, AUTH_WALLET)
|
||||||
# public (discovery + guest booking)
|
# public (discovery + guest booking)
|
||||||
register_rpc("chatelet_room_list", handle_room_list, AUTH_NONE)
|
register_rpc("chatelet_room_list", handle_room_list, AUTH_NONE)
|
||||||
register_rpc("chatelet_room_get", handle_room_get, AUTH_NONE)
|
register_rpc("chatelet_room_get", handle_room_get, AUTH_NONE)
|
||||||
|
|
|
||||||
25
crud.py
25
crud.py
|
|
@ -19,6 +19,7 @@ from .models import (
|
||||||
ChateletSettings,
|
ChateletSettings,
|
||||||
CreateBlockData,
|
CreateBlockData,
|
||||||
CreateRoomData,
|
CreateRoomData,
|
||||||
|
OperatorSettings,
|
||||||
Room,
|
Room,
|
||||||
RoomStatus,
|
RoomStatus,
|
||||||
)
|
)
|
||||||
|
|
@ -48,6 +49,30 @@ async def update_settings(settings: ChateletSettings) -> ChateletSettings:
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Operator settings (per LNbits user — multi-tenant)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_create_operator_settings(user_id: str) -> OperatorSettings:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM chatelet.operator_settings WHERE user_id = :uid",
|
||||||
|
{"uid": user_id},
|
||||||
|
OperatorSettings,
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
return row
|
||||||
|
ops = OperatorSettings(user_id=user_id)
|
||||||
|
await db.insert("chatelet.operator_settings", ops)
|
||||||
|
return ops
|
||||||
|
|
||||||
|
|
||||||
|
async def update_operator_settings(ops: OperatorSettings) -> OperatorSettings:
|
||||||
|
ops.updated_at = datetime.now(timezone.utc)
|
||||||
|
await db.update("chatelet.operator_settings", ops, "WHERE user_id = :user_id")
|
||||||
|
return ops
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Rooms
|
# Rooms
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,19 @@ replaces the same addressable event. Holds price (`amount`/`currency`/
|
||||||
published reservation object.
|
published reservation object.
|
||||||
- **`expires_at`** — hold expiry (set while `held`/`awaiting_payment`).
|
- **`expires_at`** — hold expiry (set while `held`/`awaiting_payment`).
|
||||||
|
|
||||||
|
### `operator_settings` — per LNbits user (multi-tenant, m003)
|
||||||
|
|
||||||
|
Every LNbits user may host rooms; what they decide for *all their rooms* lives
|
||||||
|
here, keyed by user id and created on first read:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `accept_fiat` | operator wants card payments. Only *offered* when LNbits core also has a fiat provider for this user (`settings.get_fiat_providers_for_user`) and the room is fiat-priced — chatelet never stores provider credentials (lnbits#67 plugs per-user Stripe creds into that same call) |
|
||||||
|
| `checkin_time`, `checkout_time`, `cancellation_policy` | house rules — shown to guests (`house_rules` on the public room view, `checkin_time`/`checkout_time` tags on the kind:30402 listing) and put in the check-in DM |
|
||||||
|
|
||||||
|
Rooms resolve their operator via `get_wallet(room.wallet).user`. The old
|
||||||
|
house-rule columns on `settings` are kept for old rows but no longer read.
|
||||||
|
|
||||||
### `blocks` — manual owner unavailability
|
### `blocks` — manual owner unavailability
|
||||||
|
|
||||||
Maintenance, personal use, off-season. Half-open `[start_date, end_date)`.
|
Maintenance, personal use, off-season. Half-open `[start_date, end_date)`.
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ flow runs over relays with no HTTP:
|
||||||
| `chatelet_room_create` / `_update` / `_publish` | wallet | operator room CRUD (ownership-checked) |
|
| `chatelet_room_create` / `_update` / `_publish` | wallet | operator room CRUD (ownership-checked) |
|
||||||
| `chatelet_block_create` | wallet | operator blocks a range |
|
| `chatelet_block_create` | wallet | operator blocks a range |
|
||||||
| `chatelet_room_list_mine` | account | operator's rooms across their wallets |
|
| `chatelet_room_list_mine` | account | operator's rooms across their wallets |
|
||||||
| `chatelet_room_list` / `_get` | none | public discovery (active rooms, wallet id stripped) |
|
| `chatelet_operator_get` / `_update` | wallet | the caller's per-operator settings (house rules, card acceptance) |
|
||||||
|
| `chatelet_room_list` / `_get` | none | public discovery (active rooms, wallet id stripped, owner's `house_rules` + `payment_methods` attached) |
|
||||||
| `chatelet_room_unavailable` | none | merged occupied/blocked spans over a window — the guest calendar feed (HTTP twin: `GET /api/v1/public/rooms/{id}/unavailable`) |
|
| `chatelet_room_unavailable` | none | merged occupied/blocked spans over a window — the guest calendar feed (HTTP twin: `GET /api/v1/public/rooms/{id}/unavailable`) |
|
||||||
| `chatelet_availability` | none | is a range free + a quote |
|
| `chatelet_availability` | none | is a range free + a quote |
|
||||||
| `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) |
|
| `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) |
|
||||||
|
|
|
||||||
|
|
@ -123,3 +123,27 @@ async def m002_room_checkin_instructions(db):
|
||||||
"ALTER TABLE chatelet.rooms ADD COLUMN checkin_instructions TEXT "
|
"ALTER TABLE chatelet.rooms ADD COLUMN checkin_instructions TEXT "
|
||||||
"NOT NULL DEFAULT '';"
|
"NOT NULL DEFAULT '';"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m003_operator_settings_and_guest_index(db):
|
||||||
|
"""Chatelet is multi-tenant: every LNbits user may host rooms. What an
|
||||||
|
operator decides for *all their rooms* — house rules and whether they
|
||||||
|
take card payments — lives here, keyed by LNbits user id, created lazily.
|
||||||
|
The single `chatelet.settings` row keeps only instance-wide knobs; its
|
||||||
|
old house-rule columns stay in place but are no longer read.
|
||||||
|
|
||||||
|
Also indexes bookings by guest so a guest can list their own stays."""
|
||||||
|
await db.execute(f"""
|
||||||
|
CREATE TABLE chatelet.operator_settings (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
accept_fiat BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
checkin_time TEXT NOT NULL DEFAULT '15:00',
|
||||||
|
checkout_time TEXT NOT NULL DEFAULT '11:00',
|
||||||
|
cancellation_policy TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
await db.execute(
|
||||||
|
"CREATE INDEX chatelet.idx_bookings_guest_pubkey ON bookings (guest_pubkey);"
|
||||||
|
)
|
||||||
|
|
|
||||||
40
models.py
40
models.py
|
|
@ -65,6 +65,28 @@ OCCUPYING_STATUSES = {
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# Per-operator (LNbits user) choices that apply to all of that user's rooms.
|
||||||
|
HOUSE_RULE_FIELDS = ("checkin_time", "checkout_time", "cancellation_policy")
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateOperatorSettings(BaseModel):
|
||||||
|
accept_fiat: bool = False
|
||||||
|
checkin_time: str = "15:00"
|
||||||
|
checkout_time: str = "11:00"
|
||||||
|
cancellation_policy: str = "" # shown to guests + in the check-in DM
|
||||||
|
|
||||||
|
|
||||||
|
class OperatorSettings(UpdateOperatorSettings):
|
||||||
|
"""One row per operator user, created on first read. `accept_fiat` is the
|
||||||
|
operator's *wish*; whether card is actually offered also depends on LNbits
|
||||||
|
core having a fiat provider for that user (services.payment_methods_for_room)
|
||||||
|
— chatelet never holds provider credentials."""
|
||||||
|
|
||||||
|
user_id: str
|
||||||
|
created_at: datetime = Field(default_factory=_now)
|
||||||
|
updated_at: datetime = Field(default_factory=_now)
|
||||||
|
|
||||||
|
|
||||||
class ChateletSettings(BaseModel):
|
class ChateletSettings(BaseModel):
|
||||||
# LNbits account whose Nostr signer publishes listings/receipts on the
|
# LNbits account whose Nostr signer publishes listings/receipts on the
|
||||||
# castle's behalf. Resolved via lnbits.core.signers.resolve_signer so
|
# castle's behalf. Resolved via lnbits.core.signers.resolve_signer so
|
||||||
|
|
@ -74,9 +96,11 @@ class ChateletSettings(BaseModel):
|
||||||
relays: list[str] = Field(default_factory=list) # where we publish/subscribe
|
relays: list[str] = Field(default_factory=list) # where we publish/subscribe
|
||||||
default_hold_minutes: int = 30 # how long a `held` booking survives unpaid
|
default_hold_minutes: int = 30 # how long a `held` booking survives unpaid
|
||||||
deposit_percent: int = 100 # 100 = full prepay; <100 = deposit + balance
|
deposit_percent: int = 100 # 100 = full prepay; <100 = deposit + balance
|
||||||
|
# Legacy (pre-m003): house rules are per operator now — see OperatorSettings.
|
||||||
|
# Columns kept so old rows load; nothing reads them.
|
||||||
checkin_time: str = "15:00"
|
checkin_time: str = "15:00"
|
||||||
checkout_time: str = "11:00"
|
checkout_time: str = "11:00"
|
||||||
cancellation_policy: str = "" # free-form markdown, surfaced in listings/DMs
|
cancellation_policy: str = ""
|
||||||
publish_availability: bool = True # mirror blocked dates to a public NIP-52 calendar
|
publish_availability: bool = True # mirror blocked dates to a public NIP-52 calendar
|
||||||
created_at: datetime = Field(default_factory=_now)
|
created_at: datetime = Field(default_factory=_now)
|
||||||
updated_at: datetime = Field(default_factory=_now)
|
updated_at: datetime = Field(default_factory=_now)
|
||||||
|
|
@ -195,14 +219,24 @@ class Block(BaseModel):
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def public_room_dict(room: Room) -> dict:
|
def public_room_dict(
|
||||||
|
room: Room,
|
||||||
|
*,
|
||||||
|
house_rules: dict | None = None,
|
||||||
|
payment_methods: list[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
"""A Room as public JSON for guests — strips operator-private fields: the
|
"""A Room as public JSON for guests — strips operator-private fields: the
|
||||||
wallet id, and the check-in instructions (address/gate code, delivered only
|
wallet id, and the check-in instructions (address/gate code, delivered only
|
||||||
in the encrypted post-payment DM). Shared by the HTTP and Nostr-RPC guest
|
in the encrypted post-payment DM). Shared by the HTTP and Nostr-RPC guest
|
||||||
doors so neither can leak them."""
|
doors so neither can leak them. `house_rules` / `payment_methods` come from
|
||||||
|
the owner's OperatorSettings (services.public_room_view resolves them)."""
|
||||||
d = json.loads(room.json())
|
d = json.loads(room.json())
|
||||||
d.pop("wallet", None)
|
d.pop("wallet", None)
|
||||||
d.pop("checkin_instructions", None)
|
d.pop("checkin_instructions", None)
|
||||||
|
if house_rules is not None:
|
||||||
|
d["house_rules"] = house_rules
|
||||||
|
if payment_methods is not None:
|
||||||
|
d["payment_methods"] = payment_methods
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,30 @@ from .kinds import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_listing_event(room: Room) -> dict:
|
def build_listing_event(
|
||||||
|
room: Room,
|
||||||
|
*,
|
||||||
|
payment_methods: list[str] | None = None,
|
||||||
|
house_rules: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
"""NIP-99 kind:30402 classified listing for a room. Public, signed by
|
"""NIP-99 kind:30402 classified listing for a room. Public, signed by
|
||||||
the operator's identity. `d` == room.id so re-publishing replaces."""
|
the operator's identity. `d` == room.id so re-publishing replaces.
|
||||||
|
|
||||||
|
`payment_methods` (comma-joined, like events' tickets_payment_methods) and
|
||||||
|
the check-in/out times ride as tags so a generic Nostr client can render
|
||||||
|
the right pay buttons and house rules without speaking our RPC."""
|
||||||
tags = [
|
tags = [
|
||||||
["d", room.id],
|
["d", room.id],
|
||||||
["title", room.title],
|
["title", room.title],
|
||||||
["price", str(room.price_amount), room.price_currency, room.price_frequency],
|
["price", str(room.price_amount), room.price_currency, room.price_frequency],
|
||||||
["status", "active"],
|
["status", "active"],
|
||||||
]
|
]
|
||||||
|
if payment_methods:
|
||||||
|
tags.append(["payment_methods", ",".join(payment_methods)])
|
||||||
|
if house_rules:
|
||||||
|
for key in ("checkin_time", "checkout_time"):
|
||||||
|
if house_rules.get(key):
|
||||||
|
tags.append([key, str(house_rules[key])])
|
||||||
if room.location:
|
if room.location:
|
||||||
tags.append(["location", room.location])
|
tags.append(["location", room.location])
|
||||||
if room.geohash:
|
if room.geohash:
|
||||||
|
|
|
||||||
|
|
@ -160,8 +160,17 @@ def _checkin_message(booking, room, settings) -> str:
|
||||||
|
|
||||||
|
|
||||||
async def publish_listing(room: Room) -> str | None:
|
async def publish_listing(room: Room) -> str | None:
|
||||||
"""Publish/refresh a room's NIP-99 kind:30402 listing (public)."""
|
"""Publish/refresh a room's NIP-99 kind:30402 listing (public), carrying
|
||||||
return await _sign_and_publish(events.build_listing_event(room))
|
the owner's rails + house rules so the event matches the API view."""
|
||||||
|
owner = await services.room_owner_id(room)
|
||||||
|
ops = await crud.get_or_create_operator_settings(owner)
|
||||||
|
return await _sign_and_publish(
|
||||||
|
events.build_listing_event(
|
||||||
|
room,
|
||||||
|
payment_methods=services.payment_methods_for_room(room, owner, ops),
|
||||||
|
house_rules=services.house_rules_dict(ops),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def publish_reservation(booking: Booking) -> str | None:
|
async def publish_reservation(booking: Booking) -> str | None:
|
||||||
|
|
|
||||||
90
services.py
90
services.py
|
|
@ -16,21 +16,27 @@ import asyncio
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from lnbits.core.crud.wallets import get_wallet
|
||||||
from lnbits.core.services import create_invoice
|
from lnbits.core.services import create_invoice
|
||||||
from lnbits.exceptions import InvoiceError
|
from lnbits.exceptions import InvoiceError
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
from lnbits.settings import settings as lnbits_settings
|
||||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||||
|
|
||||||
from . import crud
|
from . import crud
|
||||||
from .models import (
|
from .models import (
|
||||||
|
HOUSE_RULE_FIELDS,
|
||||||
AvailabilityResult,
|
AvailabilityResult,
|
||||||
Booking,
|
Booking,
|
||||||
BookingQuote,
|
BookingQuote,
|
||||||
BookingRequestData,
|
BookingRequestData,
|
||||||
BookingStatus,
|
BookingStatus,
|
||||||
DateRange,
|
DateRange,
|
||||||
|
OperatorSettings,
|
||||||
|
Room,
|
||||||
RoomStatus,
|
RoomStatus,
|
||||||
UnavailableRanges,
|
UnavailableRanges,
|
||||||
|
public_room_dict,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Per-room lock serializing the availability read + the `held` write, so two
|
# Per-room lock serializing the availability read + the `held` write, so two
|
||||||
|
|
@ -61,6 +67,90 @@ async def to_sats(amount: float, currency: str) -> int:
|
||||||
return await fiat_amount_as_satoshis(amount, currency)
|
return await fiat_amount_as_satoshis(amount, currency)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Operators + the public room view
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
LIGHTNING = "lightning"
|
||||||
|
FIAT = "fiat"
|
||||||
|
|
||||||
|
|
||||||
|
def is_fiat_currency(currency: str) -> bool:
|
||||||
|
return currency.lower() not in ("sat", "sats")
|
||||||
|
|
||||||
|
|
||||||
|
def fiat_providers_for_user(user_id: str) -> list[str]:
|
||||||
|
"""Fiat providers LNbits core will let this user charge with. The one
|
||||||
|
place chatelet consults core about card payments (lnbits#67's per-user
|
||||||
|
Stripe creds land behind this call); module-level so tests can patch it —
|
||||||
|
the pydantic settings object refuses monkeypatched methods."""
|
||||||
|
return lnbits_settings.get_fiat_providers_for_user(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def room_owner_id(room: Room) -> str:
|
||||||
|
"""The LNbits user who operates a room (rooms belong to wallets)."""
|
||||||
|
wallet = await get_wallet(room.wallet)
|
||||||
|
if not wallet:
|
||||||
|
raise NotFound("Room's wallet not found")
|
||||||
|
return wallet.user
|
||||||
|
|
||||||
|
|
||||||
|
def payment_methods_for_room(
|
||||||
|
room: Room, owner_id: str, ops: OperatorSettings
|
||||||
|
) -> list[str]:
|
||||||
|
"""Rails a guest may pay this room with. Card needs three things: the
|
||||||
|
operator opted in, LNbits core has a fiat provider for *that user* (the
|
||||||
|
single seam lnbits#67's per-user Stripe creds will plug into — chatelet
|
||||||
|
never sees credentials), and a fiat-denominated price (core cannot bill
|
||||||
|
a sat amount through a fiat provider)."""
|
||||||
|
rails = [LIGHTNING]
|
||||||
|
if (
|
||||||
|
ops.accept_fiat
|
||||||
|
and is_fiat_currency(room.price_currency)
|
||||||
|
and fiat_providers_for_user(owner_id)
|
||||||
|
):
|
||||||
|
rails.append(FIAT)
|
||||||
|
return rails
|
||||||
|
|
||||||
|
|
||||||
|
def house_rules_dict(ops: OperatorSettings) -> dict:
|
||||||
|
return {k: getattr(ops, k) for k in HOUSE_RULE_FIELDS}
|
||||||
|
|
||||||
|
|
||||||
|
async def public_room_view(room: Room) -> dict:
|
||||||
|
"""public_room_dict + the owner's house rules and rails. Used by both
|
||||||
|
guest doors so a room looks the same over HTTP and RPC."""
|
||||||
|
owner = await room_owner_id(room)
|
||||||
|
ops = await crud.get_or_create_operator_settings(owner)
|
||||||
|
return public_room_dict(
|
||||||
|
room,
|
||||||
|
house_rules=house_rules_dict(ops),
|
||||||
|
payment_methods=payment_methods_for_room(room, owner, ops),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def public_room_views(rooms: list[Room]) -> list[dict]:
|
||||||
|
"""Batch form: one owner/settings lookup per distinct wallet."""
|
||||||
|
owners: dict[str, str] = {}
|
||||||
|
ops_by_owner: dict[str, OperatorSettings] = {}
|
||||||
|
out = []
|
||||||
|
for room in rooms:
|
||||||
|
if room.wallet not in owners:
|
||||||
|
owners[room.wallet] = await room_owner_id(room)
|
||||||
|
owner = owners[room.wallet]
|
||||||
|
if owner not in ops_by_owner:
|
||||||
|
ops_by_owner[owner] = await crud.get_or_create_operator_settings(owner)
|
||||||
|
ops = ops_by_owner[owner]
|
||||||
|
out.append(
|
||||||
|
public_room_dict(
|
||||||
|
room,
|
||||||
|
house_rules=house_rules_dict(ops),
|
||||||
|
payment_methods=payment_methods_for_room(room, owner, ops),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# A guest calendar asks for a year by default; cap the window so a bad client
|
# A guest calendar asks for a year by default; cap the window so a bad client
|
||||||
# can't make us scan and ship an unbounded span.
|
# can't make us scan and ship an unbounded span.
|
||||||
DEFAULT_CALENDAR_DAYS = 365
|
DEFAULT_CALENDAR_DAYS = 365
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ window.app = Vue.createApp({
|
||||||
settings: {},
|
settings: {},
|
||||||
settingsLoading: false,
|
settingsLoading: false,
|
||||||
|
|
||||||
|
operator: {},
|
||||||
|
operatorLoading: false,
|
||||||
|
|
||||||
roomsColumns: [
|
roomsColumns: [
|
||||||
{name: 'title', label: 'Room', field: 'title', align: 'left'},
|
{name: 'title', label: 'Room', field: 'title', align: 'left'},
|
||||||
{
|
{
|
||||||
|
|
@ -237,11 +240,41 @@ window.app = Vue.createApp({
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._err(err, 'Could not save settings')
|
this._err(err, 'Could not save settings')
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- per-operator settings (house rules, card acceptance) ---
|
||||||
|
async getOperator() {
|
||||||
|
this.operatorLoading = true
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request('GET', `${API}/operator`, this.adminkey)
|
||||||
|
this.operator = data
|
||||||
|
} catch (err) {
|
||||||
|
this._err(err, 'Could not load your settings')
|
||||||
|
} finally {
|
||||||
|
this.operatorLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveOperator() {
|
||||||
|
this.operatorLoading = true
|
||||||
|
try {
|
||||||
|
const {accept_fiat, checkin_time, checkout_time, cancellation_policy} = this.operator
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'PUT', `${API}/operator`, this.adminkey,
|
||||||
|
{accept_fiat, checkin_time, checkout_time, cancellation_policy}
|
||||||
|
)
|
||||||
|
this.operator = data
|
||||||
|
Quasar.Notify.create({type: 'positive', message: 'Your settings saved'})
|
||||||
|
} catch (err) {
|
||||||
|
this._err(err, 'Could not save your settings')
|
||||||
|
} finally {
|
||||||
|
this.operatorLoading = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
created() {
|
created() {
|
||||||
this.getRooms()
|
this.getRooms()
|
||||||
this.getSettings()
|
this.getSettings()
|
||||||
|
this.getOperator()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
8
tasks.py
8
tasks.py
|
|
@ -12,7 +12,7 @@ from lnbits.core.models import Payment
|
||||||
from lnbits.tasks import register_invoice_listener
|
from lnbits.tasks import register_invoice_listener
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from . import crud
|
from . import crud, services
|
||||||
from .models import BookingStatus
|
from .models import BookingStatus
|
||||||
from .nostr import service as nostr
|
from .nostr import service as nostr
|
||||||
|
|
||||||
|
|
@ -47,9 +47,11 @@ async def on_invoice_paid(payment: Payment):
|
||||||
# Best-effort: a publish failure must not undo a confirmed, paid booking.
|
# Best-effort: a publish failure must not undo a confirmed, paid booking.
|
||||||
try:
|
try:
|
||||||
room = await crud.get_room(booking.room_id)
|
room = await crud.get_room(booking.room_id)
|
||||||
settings = await crud.get_or_create_settings()
|
|
||||||
if room:
|
if room:
|
||||||
await nostr.send_checkin_dm(booking, room, settings)
|
# House rules are the room owner's, not the instance's.
|
||||||
|
owner = await services.room_owner_id(room)
|
||||||
|
ops = await crud.get_or_create_operator_settings(owner)
|
||||||
|
await nostr.send_checkin_dm(booking, room, ops)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning(f"chatelet: check-in DM failed for {booking.id} (continuing): {exc}")
|
logger.warning(f"chatelet: check-in DM failed for {booking.id} (continuing): {exc}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,19 +171,6 @@
|
||||||
<q-input outlined dense type="number" label="Deposit %"
|
<q-input outlined dense type="number" label="Deposit %"
|
||||||
v-model.number="settings.deposit_percent"></q-input>
|
v-model.number="settings.deposit_percent"></q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<q-input outlined dense label="Check-in time"
|
|
||||||
v-model="settings.checkin_time"></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<q-input outlined dense label="Check-out time"
|
|
||||||
v-model="settings.checkout_time"></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<q-input outlined dense type="textarea" autogrow
|
|
||||||
label="Cancellation policy"
|
|
||||||
v-model="settings.cancellation_policy"></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<q-toggle v-model="settings.publish_availability"
|
<q-toggle v-model="settings.publish_availability"
|
||||||
label="Publish blocked dates to a public calendar (kind:31923)"></q-toggle>
|
label="Publish blocked dates to a public calendar (kind:31923)"></q-toggle>
|
||||||
|
|
@ -193,6 +180,48 @@
|
||||||
:loading="settingsLoading"></q-btn>
|
:loading="settingsLoading"></q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<q-separator class="q-my-lg"></q-separator>
|
||||||
|
|
||||||
|
<!-- Per-operator: applies to every room owned by this account -->
|
||||||
|
<div class="text-h6 q-mb-xs">Your rooms: house rules & payments</div>
|
||||||
|
<div class="text-caption text-grey q-mb-md">
|
||||||
|
Shown to guests on each of your rooms and sent in the check-in message.
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-md" style="max-width: 760px">
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<q-input outlined dense label="Check-in time"
|
||||||
|
v-model="operator.checkin_time"></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<q-input outlined dense label="Check-out time"
|
||||||
|
v-model="operator.checkout_time"></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<q-input outlined dense type="textarea" autogrow
|
||||||
|
label="Cancellation policy"
|
||||||
|
v-model="operator.cancellation_policy"></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<q-toggle v-model="operator.accept_fiat"
|
||||||
|
:disable="!(operator.available_fiat_providers || []).length"
|
||||||
|
label="Accept card payments for my rooms"></q-toggle>
|
||||||
|
<div class="text-caption text-grey">
|
||||||
|
<span v-if="(operator.available_fiat_providers || []).length">
|
||||||
|
Via {{ (operator.available_fiat_providers || []).join(', ') }}. Guests can pay
|
||||||
|
a fiat-priced room by card; sat-priced rooms stay Lightning-only.
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
No fiat payment provider is enabled for your account — ask the
|
||||||
|
LNbits admin to enable one (e.g. Stripe) before turning this on.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<q-btn color="primary" label="Save my settings" @click="saveOperator"
|
||||||
|
:loading="operatorLoading"></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
|
|
||||||
</q-tab-panels>
|
</q-tab-panels>
|
||||||
|
|
|
||||||
|
|
@ -86,3 +86,23 @@ def make_request(
|
||||||
check_out=check_out,
|
check_out=check_out,
|
||||||
num_guests=num_guests,
|
num_guests=num_guests,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_owner(monkeypatch, *, user_id="u1", accept_fiat=False, providers=()):
|
||||||
|
"""Route the public room view away from the core DB: rooms belong to
|
||||||
|
`user_id`, whose operator settings are fresh defaults (+ accept_fiat) and
|
||||||
|
who has `providers` enabled in LNbits core."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from .. import crud, services
|
||||||
|
from ..models import OperatorSettings
|
||||||
|
|
||||||
|
async def get_wallet(_wallet_id):
|
||||||
|
return SimpleNamespace(user=user_id)
|
||||||
|
|
||||||
|
async def ops(uid):
|
||||||
|
return OperatorSettings(user_id=uid, accept_fiat=accept_fiat)
|
||||||
|
|
||||||
|
monkeypatch.setattr(services, "get_wallet", get_wallet)
|
||||||
|
monkeypatch.setattr(crud, "get_or_create_operator_settings", ops)
|
||||||
|
monkeypatch.setattr(services, "fiat_providers_for_user", lambda _uid: list(providers))
|
||||||
|
|
|
||||||
136
tests/test_operator_settings.py
Normal file
136
tests/test_operator_settings.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""Per-operator settings (multi-tenant): what a guest sees on a room and how
|
||||||
|
the rails are derived. Chatelet never decides *whether* a user may charge
|
||||||
|
card — it asks LNbits core per owner — only whether the operator wants to."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from .. import crud, services, transport_rpcs, views_api
|
||||||
|
from ..models import OperatorSettings, RoomStatus, UpdateOperatorSettings
|
||||||
|
from ..nostr import events
|
||||||
|
from .conftest import make_room, patch_owner
|
||||||
|
|
||||||
|
|
||||||
|
def _key(wallet_id="w1", user="u1"):
|
||||||
|
return SimpleNamespace(wallet=SimpleNamespace(id=wallet_id, user=user))
|
||||||
|
|
||||||
|
|
||||||
|
def test_rails_need_flag_provider_and_fiat_price(monkeypatch):
|
||||||
|
room = make_room(price=100.0, currency="EUR")
|
||||||
|
on = OperatorSettings(user_id="u1", accept_fiat=True)
|
||||||
|
off = OperatorSettings(user_id="u1", accept_fiat=False)
|
||||||
|
lightning_only = ["lightning"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(services, "fiat_providers_for_user", lambda _u: ["stripe"])
|
||||||
|
both = ["lightning", "fiat"]
|
||||||
|
assert services.payment_methods_for_room(room, "u1", on) == both
|
||||||
|
assert services.payment_methods_for_room(room, "u1", off) == lightning_only
|
||||||
|
sat_room = make_room(price=1000.0, currency="sat")
|
||||||
|
assert services.payment_methods_for_room(sat_room, "u1", on) == lightning_only
|
||||||
|
monkeypatch.setattr(services, "fiat_providers_for_user", lambda _u: [])
|
||||||
|
assert services.payment_methods_for_room(room, "u1", on) == lightning_only
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_room_view_attaches_rules_and_rails(monkeypatch):
|
||||||
|
patch_owner(monkeypatch, accept_fiat=True, providers=["stripe"])
|
||||||
|
room = make_room("a", status=RoomStatus.active, currency="EUR")
|
||||||
|
out = asyncio.run(services.public_room_view(room))
|
||||||
|
assert out["payment_methods"] == ["lightning", "fiat"]
|
||||||
|
assert out["house_rules"] == {
|
||||||
|
"checkin_time": "15:00",
|
||||||
|
"checkout_time": "11:00",
|
||||||
|
"cancellation_policy": "",
|
||||||
|
}
|
||||||
|
assert "wallet" not in out and "checkin_instructions" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_view_looks_owner_up_once_per_wallet(monkeypatch):
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
async def get_wallet(wallet_id):
|
||||||
|
calls.append(wallet_id)
|
||||||
|
return SimpleNamespace(user="u1")
|
||||||
|
|
||||||
|
async def ops(uid):
|
||||||
|
return OperatorSettings(user_id=uid)
|
||||||
|
|
||||||
|
monkeypatch.setattr(services, "get_wallet", get_wallet)
|
||||||
|
monkeypatch.setattr(crud, "get_or_create_operator_settings", ops)
|
||||||
|
monkeypatch.setattr(services, "fiat_providers_for_user", lambda _u: [])
|
||||||
|
rooms = [
|
||||||
|
make_room("a", wallet="w1"),
|
||||||
|
make_room("b", wallet="w1"),
|
||||||
|
make_room("c", wallet="w2"),
|
||||||
|
]
|
||||||
|
out = asyncio.run(services.public_room_views(rooms))
|
||||||
|
assert [r["id"] for r in out] == ["a", "b", "c"]
|
||||||
|
assert sorted(calls) == ["w1", "w2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_event_carries_rails_and_times():
|
||||||
|
room = make_room("a")
|
||||||
|
ev = events.build_listing_event(
|
||||||
|
room,
|
||||||
|
payment_methods=["lightning", "fiat"],
|
||||||
|
house_rules={
|
||||||
|
"checkin_time": "16:00",
|
||||||
|
"checkout_time": "10:00",
|
||||||
|
"cancellation_policy": "x",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
wanted = ("payment_methods", "checkin_time", "checkout_time")
|
||||||
|
tags = {t[0]: t[1:] for t in ev["tags"] if t[0] in wanted}
|
||||||
|
assert tags == {
|
||||||
|
"payment_methods": ["lightning,fiat"],
|
||||||
|
"checkin_time": ["16:00"],
|
||||||
|
"checkout_time": ["10:00"],
|
||||||
|
}
|
||||||
|
# long-form policy text stays off the listing tags
|
||||||
|
assert not any(t[0] == "cancellation_policy" for t in ev["tags"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_operator_endpoints_scope_to_calling_user(monkeypatch):
|
||||||
|
store: dict[str, OperatorSettings] = {}
|
||||||
|
|
||||||
|
async def get_or_create(uid):
|
||||||
|
return store.setdefault(uid, OperatorSettings(user_id=uid))
|
||||||
|
|
||||||
|
async def update(ops):
|
||||||
|
store[ops.user_id] = ops
|
||||||
|
return ops
|
||||||
|
|
||||||
|
async def no_rooms():
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(crud, "get_or_create_operator_settings", get_or_create)
|
||||||
|
monkeypatch.setattr(crud, "update_operator_settings", update)
|
||||||
|
monkeypatch.setattr(crud, "get_rooms", no_rooms)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
services, "fiat_providers_for_user", lambda u: ["stripe"] if u == "u1" else []
|
||||||
|
)
|
||||||
|
|
||||||
|
first = asyncio.run(views_api.api_get_operator_settings(key=_key(user="u1")))
|
||||||
|
assert first["user_id"] == "u1" and first["accept_fiat"] is False
|
||||||
|
assert first["available_fiat_providers"] == ["stripe"]
|
||||||
|
|
||||||
|
updated = asyncio.run(
|
||||||
|
views_api.api_update_operator_settings(
|
||||||
|
UpdateOperatorSettings(accept_fiat=True, checkin_time="16:00"),
|
||||||
|
key=_key(user="u1"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert updated["accept_fiat"] is True and updated["checkin_time"] == "16:00"
|
||||||
|
|
||||||
|
other = asyncio.run(views_api.api_get_operator_settings(key=_key(user="u2")))
|
||||||
|
assert other["accept_fiat"] is False and other["available_fiat_providers"] == []
|
||||||
|
|
||||||
|
# RPC twin reads the same row for the same wallet user
|
||||||
|
rpc = asyncio.run(
|
||||||
|
transport_rpcs.handle_operator_get(
|
||||||
|
_key(user="u1"),
|
||||||
|
transport_rpcs.NostrRpcRequest(
|
||||||
|
rpc_name="chatelet_operator_get", request_id="r", body={}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert rpc["accept_fiat"] is True and rpc["checkin_time"] == "16:00"
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
(privacy: check-in instructions must never reach a guest)."""
|
(privacy: check-in instructions must never reach a guest)."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
@ -14,7 +15,7 @@ from ..models import (
|
||||||
public_booking_dict,
|
public_booking_dict,
|
||||||
public_room_dict,
|
public_room_dict,
|
||||||
)
|
)
|
||||||
from .conftest import make_room
|
from .conftest import make_room, patch_owner
|
||||||
|
|
||||||
|
|
||||||
def test_public_room_dict_strips_private_fields():
|
def test_public_room_dict_strips_private_fields():
|
||||||
|
|
@ -36,10 +37,13 @@ def test_public_rooms_lists_active_only_and_stripped(monkeypatch):
|
||||||
return [active, inactive]
|
return [active, inactive]
|
||||||
|
|
||||||
monkeypatch.setattr(crud, "get_rooms", gr)
|
monkeypatch.setattr(crud, "get_rooms", gr)
|
||||||
|
patch_owner(monkeypatch)
|
||||||
out = asyncio.run(views_api.api_public_rooms())
|
out = asyncio.run(views_api.api_public_rooms())
|
||||||
assert [r["id"] for r in out] == ["a"] # inactive hidden from guests
|
assert [r["id"] for r in out] == ["a"] # inactive hidden from guests
|
||||||
assert "checkin_instructions" not in out[0]
|
assert "checkin_instructions" not in out[0]
|
||||||
assert "wallet" not in out[0]
|
assert "wallet" not in out[0]
|
||||||
|
assert out[0]["house_rules"]["checkin_time"] == "15:00"
|
||||||
|
assert out[0]["payment_methods"] == ["lightning"]
|
||||||
|
|
||||||
|
|
||||||
def test_public_room_404_when_inactive(monkeypatch):
|
def test_public_room_404_when_inactive(monkeypatch):
|
||||||
|
|
@ -60,14 +64,15 @@ def test_public_room_returns_stripped_when_active(monkeypatch):
|
||||||
return room
|
return room
|
||||||
|
|
||||||
monkeypatch.setattr(crud, "get_room", gr)
|
monkeypatch.setattr(crud, "get_room", gr)
|
||||||
|
patch_owner(monkeypatch)
|
||||||
out = asyncio.run(views_api.api_public_room("a"))
|
out = asyncio.run(views_api.api_public_room("a"))
|
||||||
assert out["id"] == "a"
|
assert out["id"] == "a"
|
||||||
assert "checkin_instructions" not in out
|
assert "checkin_instructions" not in out
|
||||||
assert "wallet" not in out
|
assert "wallet" not in out
|
||||||
|
|
||||||
|
|
||||||
def _booking(**overrides) -> Booking:
|
def _booking(**overrides: Any) -> Booking:
|
||||||
base = {
|
base: dict[str, Any] = {
|
||||||
"id": "bk_1234567",
|
"id": "bk_1234567",
|
||||||
"room_id": "a",
|
"room_id": "a",
|
||||||
"guest_pubkey": "ab" * 32,
|
"guest_pubkey": "ab" * 32,
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,13 @@ from lnbits.core.models.wallets import WalletTypeInfo
|
||||||
from lnbits.core.services.nostr_transport.models import NostrRpcRequest
|
from lnbits.core.services.nostr_transport.models import NostrRpcRequest
|
||||||
|
|
||||||
from . import crud, services
|
from . import crud, services
|
||||||
from .models import BookingRequestData, CreateBlockData, CreateRoomData, RoomStatus
|
from .models import (
|
||||||
|
BookingRequestData,
|
||||||
|
CreateBlockData,
|
||||||
|
CreateRoomData,
|
||||||
|
RoomStatus,
|
||||||
|
UpdateOperatorSettings,
|
||||||
|
)
|
||||||
|
|
||||||
# Fields a client may patch on a room via chatelet_room_update. Identity /
|
# Fields a client may patch on a room via chatelet_room_update. Identity /
|
||||||
# counter fields (id, wallet, listing_event_id, created_at) are not mutable;
|
# counter fields (id, wallet, listing_event_id, created_at) are not mutable;
|
||||||
|
|
@ -99,20 +105,36 @@ async def handle_block_create(auth: WalletTypeInfo, request: NostrRpcRequest) ->
|
||||||
return _to_dict(block)
|
return _to_dict(block)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_operator_get(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict:
|
||||||
|
ops = await crud.get_or_create_operator_settings(auth.wallet.user)
|
||||||
|
return _to_dict(ops)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_operator_update(
|
||||||
|
auth: WalletTypeInfo, request: NostrRpcRequest
|
||||||
|
) -> dict:
|
||||||
|
ops = await crud.get_or_create_operator_settings(auth.wallet.user)
|
||||||
|
for k, v in (request.body or {}).items():
|
||||||
|
if k in UpdateOperatorSettings.__fields__:
|
||||||
|
setattr(ops, k, v)
|
||||||
|
return _to_dict(await crud.update_operator_settings(ops))
|
||||||
|
|
||||||
|
|
||||||
# --- public: discovery + booking (AUTH_NONE) -------------------------------
|
# --- public: discovery + booking (AUTH_NONE) -------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def handle_room_list(auth: None, request: NostrRpcRequest) -> list[dict]:
|
async def handle_room_list(auth: None, request: NostrRpcRequest) -> list[dict]:
|
||||||
"""Active rooms only, wallet id stripped (public discovery)."""
|
"""Active rooms only, wallet id stripped, owner's house rules + rails
|
||||||
rooms = await crud.get_rooms()
|
attached (public discovery) — same view as the HTTP door."""
|
||||||
return [_public_room(r) for r in rooms if r.status == RoomStatus.active]
|
rooms = [r for r in await crud.get_rooms() if r.status == RoomStatus.active]
|
||||||
|
return await services.public_room_views(rooms)
|
||||||
|
|
||||||
|
|
||||||
async def handle_room_get(auth: None, request: NostrRpcRequest) -> dict:
|
async def handle_room_get(auth: None, request: NostrRpcRequest) -> dict:
|
||||||
room = await crud.get_room(_require_id(request))
|
room = await crud.get_room(_require_id(request))
|
||||||
if not room or room.status != RoomStatus.active:
|
if not room or room.status != RoomStatus.active:
|
||||||
raise ValueError("Room not available")
|
raise ValueError("Room not available")
|
||||||
return _public_room(room)
|
return await services.public_room_view(room)
|
||||||
|
|
||||||
|
|
||||||
async def handle_room_unavailable(auth: None, request: NostrRpcRequest) -> dict:
|
async def handle_room_unavailable(auth: None, request: NostrRpcRequest) -> dict:
|
||||||
|
|
@ -204,11 +226,3 @@ async def _require_owned_room(room_id: str, wallet_id: str):
|
||||||
def _to_dict(obj) -> dict:
|
def _to_dict(obj) -> dict:
|
||||||
return json.loads(obj.json())
|
return json.loads(obj.json())
|
||||||
|
|
||||||
|
|
||||||
def _public_room(room) -> dict:
|
|
||||||
# Shared with the HTTP door; strips wallet id AND checkin_instructions
|
|
||||||
# (the latter was leaking to guests before — added after this file's
|
|
||||||
# original public dict).
|
|
||||||
from .models import public_room_dict
|
|
||||||
|
|
||||||
return public_room_dict(room)
|
|
||||||
|
|
|
||||||
51
views_api.py
51
views_api.py
|
|
@ -22,11 +22,12 @@ from .models import (
|
||||||
ChateletSettings,
|
ChateletSettings,
|
||||||
CreateBlockData,
|
CreateBlockData,
|
||||||
CreateRoomData,
|
CreateRoomData,
|
||||||
|
OperatorSettings,
|
||||||
Room,
|
Room,
|
||||||
RoomStatus,
|
RoomStatus,
|
||||||
UnavailableRanges,
|
UnavailableRanges,
|
||||||
|
UpdateOperatorSettings,
|
||||||
public_booking_dict,
|
public_booking_dict,
|
||||||
public_room_dict,
|
|
||||||
)
|
)
|
||||||
from .nostr import service as nostr
|
from .nostr import service as nostr
|
||||||
|
|
||||||
|
|
@ -192,17 +193,51 @@ async def api_update_settings(
|
||||||
return await crud.update_settings(settings)
|
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) --------------------------------------
|
# --- public guest discovery (no auth) --------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@chatelet_api_router.get("/api/v1/public/rooms")
|
@chatelet_api_router.get("/api/v1/public/rooms")
|
||||||
async def api_public_rooms() -> list[dict]:
|
async def api_public_rooms() -> list[dict]:
|
||||||
"""Active rooms for guest browsing — operator-private fields stripped."""
|
"""Active rooms for guests — wallet + check-in instructions stripped,
|
||||||
return [
|
owner's house rules + accepted rails attached."""
|
||||||
public_room_dict(r)
|
rooms = [r for r in await crud.get_rooms() if r.status == RoomStatus.active]
|
||||||
for r in await crud.get_rooms()
|
return await services.public_room_views(rooms)
|
||||||
if r.status == RoomStatus.active
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@chatelet_api_router.get("/api/v1/public/rooms/{room_id}")
|
@chatelet_api_router.get("/api/v1/public/rooms/{room_id}")
|
||||||
|
|
@ -210,7 +245,7 @@ async def api_public_room(room_id: str) -> dict:
|
||||||
room = await crud.get_room(room_id)
|
room = await crud.get_room(room_id)
|
||||||
if not room or room.status != RoomStatus.active:
|
if not room or room.status != RoomStatus.active:
|
||||||
raise HTTPException(404, "Room not available")
|
raise HTTPException(404, "Room not available")
|
||||||
return public_room_dict(room)
|
return await services.public_room_view(room)
|
||||||
|
|
||||||
|
|
||||||
@chatelet_api_router.get("/api/v1/public/rooms/{room_id}/unavailable")
|
@chatelet_api_router.get("/api/v1/public/rooms/{room_id}/unavailable")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue