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:
parent
64607a00bc
commit
feaaef93e3
8 changed files with 278 additions and 8 deletions
58
crud.py
58
crud.py
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue