diff --git a/__init__.py b/__init__.py index b2e1139..a8fa672 100644 --- a/__init__.py +++ b/__init__.py @@ -73,7 +73,6 @@ def chatelet_start(): handle_room_list, handle_room_list_mine, handle_room_publish, - handle_room_unavailable, handle_room_update, resolve_chatelet_owner, ) @@ -87,7 +86,6 @@ 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 765d703..8ba75dd 100644 --- a/crud.py +++ b/crud.py @@ -12,7 +12,6 @@ from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash from .models import ( - OCCUPYING_STATUSES, Block, Booking, BookingStatus, @@ -189,9 +188,12 @@ 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 OCCUPYING_STATUSES and _overlaps( - check_in, check_out, b.check_in, b.check_out - ): + 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): return False for blk in await get_blocks_for_room(room_id): @@ -201,54 +203,6 @@ 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 8524ab4..8fce44a 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -26,7 +26,6 @@ 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 346b4e0..77d4537 100644 --- a/models.py +++ b/models.py @@ -231,27 +231,6 @@ 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 ea6e4f9..7607fa8 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 date, datetime, timedelta, timezone +from datetime import datetime, timedelta, timezone from lnbits.core.services import create_invoice from lnbits.exceptions import InvoiceError @@ -28,9 +28,7 @@ from .models import ( BookingQuote, BookingRequestData, BookingStatus, - DateRange, RoomStatus, - UnavailableRanges, ) # Per-room lock serializing the availability read + the `held` write, so two @@ -61,46 +59,6 @@ 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 deleted file mode 100644 index c5d7203..0000000 --- a/tests/test_unavailable_ranges.py +++ /dev/null @@ -1,133 +0,0 @@ -"""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 71ee687..d3e5cc3 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -115,16 +115,6 @@ 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 2e306fd..cc45519 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, Query +from fastapi import APIRouter, Depends, HTTPException from lnbits.core.models import WalletTypeInfo from lnbits.decorators import require_admin_key, require_invoice_key @@ -24,7 +24,6 @@ from .models import ( CreateRoomData, Room, RoomStatus, - UnavailableRanges, public_booking_dict, public_room_dict, ) @@ -213,20 +212,6 @@ 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) --------------------------------------------