diff --git a/__init__.py b/__init__.py index a8fa672..b2e1139 100644 --- a/__init__.py +++ b/__init__.py @@ -73,6 +73,7 @@ def chatelet_start(): handle_room_list, handle_room_list_mine, handle_room_publish, + handle_room_unavailable, handle_room_update, resolve_chatelet_owner, ) @@ -86,6 +87,7 @@ def chatelet_start(): # public (discovery + guest booking) register_rpc("chatelet_room_list", handle_room_list, 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_booking_request", handle_booking_request, AUTH_NONE) register_rpc("chatelet_booking_get", handle_booking_get, AUTH_NONE) diff --git a/crud.py b/crud.py index 8ba75dd..765d703 100644 --- a/crud.py +++ b/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) diff --git a/docs/event-flow.md b/docs/event-flow.md index 8fce44a..8524ab4 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -26,6 +26,7 @@ flow runs over relays with no HTTP: | `chatelet_block_create` | wallet | operator blocks a range | | `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_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_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`) | diff --git a/models.py b/models.py index 77d4537..346b4e0 100644 --- a/models.py +++ b/models.py @@ -231,6 +231,27 @@ class AvailabilityQuery(BaseModel): 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): room_id: str check_in: str diff --git a/services.py b/services.py index 7607fa8..ea6e4f9 100644 --- a/services.py +++ b/services.py @@ -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: diff --git a/tests/test_unavailable_ranges.py b/tests/test_unavailable_ranges.py new file mode 100644 index 0000000..c5d7203 --- /dev/null +++ b/tests/test_unavailable_ranges.py @@ -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 diff --git a/transport_rpcs.py b/transport_rpcs.py index d3e5cc3..71ee687 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -115,6 +115,16 @@ async def handle_room_get(auth: None, request: NostrRpcRequest) -> dict: 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: body = request.body or {} result = await services.get_availability( diff --git a/views_api.py b/views_api.py index cc45519..2e306fd 100644 --- a/views_api.py +++ b/views_api.py @@ -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. """ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from lnbits.core.models import WalletTypeInfo from lnbits.decorators import require_admin_key, require_invoice_key @@ -24,6 +24,7 @@ from .models import ( CreateRoomData, Room, RoomStatus, + UnavailableRanges, public_booking_dict, public_room_dict, ) @@ -212,6 +213,20 @@ async def api_public_room(room_id: str) -> dict: 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) --------------------------------------------