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

58
crud.py
View file

@ -12,6 +12,7 @@ from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash
from .models import (
OCCUPYING_STATUSES,
Block,
Booking,
BookingStatus,
@ -188,12 +189,9 @@ async def is_available(room_id: str, check_in: str, check_out: str) -> bool:
return False
for b in await get_bookings_for_room(room_id):
if b.status in (
BookingStatus.held,
BookingStatus.awaiting_payment,
BookingStatus.confirmed,
BookingStatus.checked_in,
) and _overlaps(check_in, check_out, b.check_in, b.check_out):
if b.status in OCCUPYING_STATUSES and _overlaps(
check_in, check_out, b.check_in, b.check_out
):
return False
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
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:
d_in = date.fromisoformat(check_in)
d_out = date.fromisoformat(check_out)