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

@ -14,7 +14,7 @@ backend failure and surfaces as a generic error over RPC (logged server-side).
import asyncio
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.exceptions import InvoiceError
@ -28,7 +28,9 @@ from .models import (
BookingQuote,
BookingRequestData,
BookingStatus,
DateRange,
RoomStatus,
UnavailableRanges,
)
# 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)
# 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(
room_id: str, check_in: str, check_out: str
) -> AvailabilityResult: