feat(api): keyless unavailable-ranges feed for the guest calendar

GET /api/v1/public/rooms/{id}/unavailable?start=&end= and its RPC twin
chatelet_room_unavailable return the nights a guest cannot book over a
window (default today → +365 d, capped at 400 d): occupying bookings —
live holds included, so the feed always agrees with POST /availability —
and manual blocks, clipped, sorted and coalesced into anonymous half-open
spans. Adjacent spans merge on purpose so a guest cannot tell where one
occupant's dates end and the next begin, nor a block from a stay.

is_available now uses the OCCUPYING_STATUSES constant it was inlining.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-16 11:45:51 +02:00
commit feaaef93e3
8 changed files with 278 additions and 8 deletions

View file

@ -73,6 +73,7 @@ def chatelet_start():
handle_room_list, handle_room_list,
handle_room_list_mine, handle_room_list_mine,
handle_room_publish, handle_room_publish,
handle_room_unavailable,
handle_room_update, handle_room_update,
resolve_chatelet_owner, resolve_chatelet_owner,
) )
@ -86,6 +87,7 @@ def chatelet_start():
# 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)
register_rpc("chatelet_room_unavailable", handle_room_unavailable, AUTH_NONE)
register_rpc("chatelet_availability", handle_availability, AUTH_NONE) register_rpc("chatelet_availability", handle_availability, AUTH_NONE)
register_rpc("chatelet_booking_request", handle_booking_request, AUTH_NONE) register_rpc("chatelet_booking_request", handle_booking_request, AUTH_NONE)
register_rpc("chatelet_booking_get", handle_booking_get, AUTH_NONE) register_rpc("chatelet_booking_get", handle_booking_get, AUTH_NONE)

58
crud.py
View file

@ -12,6 +12,7 @@ from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from .models import ( from .models import (
OCCUPYING_STATUSES,
Block, Block,
Booking, Booking,
BookingStatus, BookingStatus,
@ -188,12 +189,9 @@ async def is_available(room_id: str, check_in: str, check_out: str) -> bool:
return False return False
for b in await get_bookings_for_room(room_id): for b in await get_bookings_for_room(room_id):
if b.status in ( if b.status in OCCUPYING_STATUSES and _overlaps(
BookingStatus.held, check_in, check_out, b.check_in, b.check_out
BookingStatus.awaiting_payment, ):
BookingStatus.confirmed,
BookingStatus.checked_in,
) and _overlaps(check_in, check_out, b.check_in, b.check_out):
return False return False
for blk in await get_blocks_for_room(room_id): for blk in await get_blocks_for_room(room_id):
@ -203,6 +201,54 @@ async def is_available(room_id: str, check_in: str, check_out: str) -> bool:
return True return True
def merge_ranges(
ranges: list[tuple[str, str]], start: str, end: str
) -> list[tuple[str, str]]:
"""Clip half-open [s, e) spans to [start, end), drop empties, sort, and
coalesce overlapping *and adjacent* spans (a stay ending the day another
begins becomes one span). Pure, so the calendar shape is unit-testable
without a DB. Adjacent merging is deliberate: it hides where one
occupant's dates stop and the next's begin."""
clipped = sorted(
(max(s, start), min(e, end)) for s, e in ranges if max(s, start) < min(e, end)
)
merged: list[tuple[str, str]] = []
for s, e in clipped:
if merged and s <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
else:
merged.append((s, e))
return merged
async def get_occupied_ranges(
room_id: str, start: str, end: str
) -> list[tuple[str, str]]:
"""Occupying bookings + blocks that touch [start, end), as merged spans.
Same statuses and the same half-open rule as `is_available`, so a night
the calendar shows as free is one the arbiter will accept."""
bookings = await db.fetchall(
f"""
SELECT * FROM chatelet.bookings
WHERE room_id = :rid AND check_in < :end AND check_out > :start
AND status IN ({", ".join(f"'{st.value}'" for st in OCCUPYING_STATUSES)})
""",
{"rid": room_id, "start": start, "end": end},
Booking,
)
blocks = await db.fetchall(
"""
SELECT * FROM chatelet.blocks
WHERE room_id = :rid AND start_date < :end AND end_date > :start
""",
{"rid": room_id, "start": start, "end": end},
Block,
)
spans = [(b.check_in, b.check_out) for b in bookings]
spans += [(blk.start_date, blk.end_date) for blk in blocks]
return merge_ranges(spans, start, end)
def nights_between(check_in: str, check_out: str) -> int: def nights_between(check_in: str, check_out: str) -> int:
d_in = date.fromisoformat(check_in) d_in = date.fromisoformat(check_in)
d_out = date.fromisoformat(check_out) d_out = date.fromisoformat(check_out)

View file

@ -26,6 +26,7 @@ flow runs over relays with no HTTP:
| `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_room_list` / `_get` | none | public discovery (active rooms, wallet id stripped) |
| `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`) |
| `chatelet_booking_get` | none | guest reads back their booking (ownership by `sender_pubkey`) | | `chatelet_booking_get` | none | guest reads back their booking (ownership by `sender_pubkey`) |

View file

@ -231,6 +231,27 @@ class AvailabilityQuery(BaseModel):
check_out: str # YYYY-MM-DD exclusive check_out: str # YYYY-MM-DD exclusive
class DateRange(BaseModel):
"""Half-open [start, end) span of nights, YYYY-MM-DD. `end` is the morning
the room frees up — a guest may check in on that day."""
start: str
end: str
class UnavailableRanges(BaseModel):
"""Everything a guest calendar needs to grey out nights for one room
over a window: bookings that still occupy the room (incl. live unpaid
holds, so this always agrees with `POST /availability`) and manual
blocks, merged into indistinguishable date spans — a guest can't tell a
block from another guest's stay."""
room_id: str
start: str
end: str
ranges: list[DateRange] = Field(default_factory=list)
class AvailabilityResult(BaseModel): class AvailabilityResult(BaseModel):
room_id: str room_id: str
check_in: str check_in: str

View file

@ -14,7 +14,7 @@ backend failure and surfaces as a generic error over RPC (logged server-side).
import asyncio import asyncio
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from lnbits.core.services import create_invoice from lnbits.core.services import create_invoice
from lnbits.exceptions import InvoiceError from lnbits.exceptions import InvoiceError
@ -28,7 +28,9 @@ from .models import (
BookingQuote, BookingQuote,
BookingRequestData, BookingRequestData,
BookingStatus, BookingStatus,
DateRange,
RoomStatus, RoomStatus,
UnavailableRanges,
) )
# Per-room lock serializing the availability read + the `held` write, so two # Per-room lock serializing the availability read + the `held` write, so two
@ -59,6 +61,46 @@ 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)
# 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.
DEFAULT_CALENDAR_DAYS = 365
MAX_CALENDAR_DAYS = 400
async def get_unavailable_ranges(
room_id: str, start: str | None = None, end: str | None = None
) -> UnavailableRanges:
"""Occupied/blocked nights for one active room over [start, end).
Defaults to today → +365 days (UTC dates)."""
room = await crud.get_room(room_id)
if not room or room.status != RoomStatus.active:
raise NotFound("Room not available")
try:
d_start = (
date.fromisoformat(start) if start else datetime.now(timezone.utc).date()
)
d_end = (
date.fromisoformat(end)
if end
else d_start + timedelta(days=DEFAULT_CALENDAR_DAYS)
)
except ValueError as exc:
raise ValueError("Dates must be YYYY-MM-DD") from exc
if d_end <= d_start:
raise ValueError("end must be after start")
if (d_end - d_start).days > MAX_CALENDAR_DAYS:
raise ValueError(f"Window may span at most {MAX_CALENDAR_DAYS} days")
spans = await crud.get_occupied_ranges(
room_id, d_start.isoformat(), d_end.isoformat()
)
return UnavailableRanges(
room_id=room_id,
start=d_start.isoformat(),
end=d_end.isoformat(),
ranges=[DateRange(start=s, end=e) for s, e in spans],
)
async def get_availability( async def get_availability(
room_id: str, check_in: str, check_out: str room_id: str, check_in: str, check_out: str
) -> AvailabilityResult: ) -> AvailabilityResult:

View file

@ -0,0 +1,133 @@
"""Guest calendar feed: merged, anonymous occupied spans for one room."""
import asyncio
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import HTTPException
from .. import crud, services, transport_rpcs, views_api
from ..models import RoomStatus
from .conftest import make_room
# --- merge_ranges (pure) ---------------------------------------------------
def test_merge_sorts_and_coalesces_overlap_and_adjacency():
spans = [
("2026-10-10", "2026-10-12"),
("2026-10-01", "2026-10-04"),
("2026-10-04", "2026-10-06"), # adjacent to the first: one span
("2026-10-11", "2026-10-15"), # overlaps the 10-12 stay
]
out = crud.merge_ranges(spans, "2026-09-01", "2027-09-01")
assert out == [("2026-10-01", "2026-10-06"), ("2026-10-10", "2026-10-15")]
def test_merge_clips_to_window_and_drops_outside():
spans = [
("2026-08-20", "2026-09-05"), # straddles the window start
("2026-12-28", "2027-01-10"), # straddles the window end
("2026-07-01", "2026-07-03"), # entirely before
("2027-02-01", "2027-02-03"), # entirely after
]
out = crud.merge_ranges(spans, "2026-09-01", "2027-01-01")
assert out == [("2026-09-01", "2026-09-05"), ("2026-12-28", "2027-01-01")]
def test_merge_empty():
assert crud.merge_ranges([], "2026-09-01", "2027-01-01") == []
# --- services.get_unavailable_ranges ----------------------------------------
def _patch(monkeypatch, room, spans):
seen: dict = {}
async def gr(_):
return room
async def occupied(room_id, start, end):
seen.update(room_id=room_id, start=start, end=end)
return spans
monkeypatch.setattr(crud, "get_room", gr)
monkeypatch.setattr(crud, "get_occupied_ranges", occupied)
return seen
def test_defaults_to_today_plus_365(monkeypatch):
seen = _patch(monkeypatch, make_room("a", status=RoomStatus.active), [])
out = asyncio.run(services.get_unavailable_ranges("a"))
today = datetime.now(timezone.utc).date()
assert out.start == today.isoformat()
assert out.end == (today + timedelta(days=365)).isoformat()
assert (seen["start"], seen["end"]) == (out.start, out.end)
assert out.ranges == []
def test_explicit_window_and_shape(monkeypatch):
_patch(
monkeypatch,
make_room("a", status=RoomStatus.active),
[("2026-10-05", "2026-10-07")],
)
out = asyncio.run(services.get_unavailable_ranges("a", "2026-10-01", "2026-11-01"))
assert (out.room_id, out.start, out.end) == ("a", "2026-10-01", "2026-11-01")
assert [(r.start, r.end) for r in out.ranges] == [("2026-10-05", "2026-10-07")]
@pytest.mark.parametrize(
("start", "end"),
[
("2026-10-10", "2026-10-10"), # empty window
("2026-10-10", "2026-10-01"), # reversed
("2026-01-01", "2027-03-01"), # > 400 days
("not-a-date", None),
],
)
def test_window_validation(monkeypatch, start, end):
_patch(monkeypatch, make_room("a", status=RoomStatus.active), [])
with pytest.raises(ValueError):
asyncio.run(services.get_unavailable_ranges("a", start, end))
def test_inactive_room_is_not_found(monkeypatch):
_patch(monkeypatch, make_room("a", status=RoomStatus.inactive), [])
with pytest.raises(services.NotFound):
asyncio.run(services.get_unavailable_ranges("a"))
# --- both doors -------------------------------------------------------------
def test_http_and_rpc_doors_agree(monkeypatch):
_patch(
monkeypatch,
make_room("a", status=RoomStatus.active),
[("2026-10-05", "2026-10-07")],
)
http = asyncio.run(
views_api.api_public_room_unavailable("a", "2026-10-01", "2026-11-01")
)
rpc = asyncio.run(
transport_rpcs.handle_room_unavailable(
None,
transport_rpcs.NostrRpcRequest(
rpc_name="chatelet_room_unavailable",
request_id="req-1",
body={"room_id": "a", "start": "2026-10-01", "end": "2026-11-01"},
sender_pubkey="ab" * 32,
),
)
)
assert rpc == http.dict()
assert rpc["ranges"] == [{"start": "2026-10-05", "end": "2026-10-07"}]
def test_http_maps_errors(monkeypatch):
_patch(monkeypatch, make_room("a", status=RoomStatus.inactive), [])
with pytest.raises(HTTPException) as e:
asyncio.run(views_api.api_public_room_unavailable("a", None, None))
assert e.value.status_code == 404

View file

@ -115,6 +115,16 @@ async def handle_room_get(auth: None, request: NostrRpcRequest) -> dict:
return _public_room(room) return _public_room(room)
async def handle_room_unavailable(auth: None, request: NostrRpcRequest) -> dict:
"""Merged occupied/blocked spans for one room — the calendar feed.
Body: {room_id, start?, end?} (YYYY-MM-DD, end exclusive)."""
body = request.body or {}
result = await services.get_unavailable_ranges(
_require(body, "room_id"), body.get("start"), body.get("end")
)
return _to_dict(result)
async def handle_availability(auth: None, request: NostrRpcRequest) -> dict: async def handle_availability(auth: None, request: NostrRpcRequest) -> dict:
body = request.body or {} body = request.body or {}
result = await services.get_availability( result = await services.get_availability(

View file

@ -7,7 +7,7 @@ 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. the guest-facing surface (availability, booking) is what also rides the RPC.
""" """
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from lnbits.core.models import WalletTypeInfo from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import require_admin_key, require_invoice_key from lnbits.decorators import require_admin_key, require_invoice_key
@ -24,6 +24,7 @@ from .models import (
CreateRoomData, CreateRoomData,
Room, Room,
RoomStatus, RoomStatus,
UnavailableRanges,
public_booking_dict, public_booking_dict,
public_room_dict, public_room_dict,
) )
@ -212,6 +213,20 @@ async def api_public_room(room_id: str) -> dict:
return public_room_dict(room) return public_room_dict(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) -------------------------------------------- # --- availability (public read) --------------------------------------------