Compare commits
2 commits
21aa6ea7c3
...
b5b5141ea4
| Author | SHA1 | Date | |
|---|---|---|---|
| b5b5141ea4 | |||
| feaaef93e3 |
8 changed files with 278 additions and 8 deletions
|
|
@ -73,6 +73,7 @@ def chatelet_start():
|
||||||
handle_room_list,
|
handle_room_list,
|
||||||
handle_room_list_mine,
|
handle_room_list_mine,
|
||||||
handle_room_publish,
|
handle_room_publish,
|
||||||
|
handle_room_unavailable,
|
||||||
handle_room_update,
|
handle_room_update,
|
||||||
resolve_chatelet_owner,
|
resolve_chatelet_owner,
|
||||||
)
|
)
|
||||||
|
|
@ -86,6 +87,7 @@ def chatelet_start():
|
||||||
# public (discovery + guest booking)
|
# public (discovery + guest booking)
|
||||||
register_rpc("chatelet_room_list", handle_room_list, AUTH_NONE)
|
register_rpc("chatelet_room_list", handle_room_list, AUTH_NONE)
|
||||||
register_rpc("chatelet_room_get", handle_room_get, 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_availability", handle_availability, AUTH_NONE)
|
||||||
register_rpc("chatelet_booking_request", handle_booking_request, AUTH_NONE)
|
register_rpc("chatelet_booking_request", handle_booking_request, AUTH_NONE)
|
||||||
register_rpc("chatelet_booking_get", handle_booking_get, AUTH_NONE)
|
register_rpc("chatelet_booking_get", handle_booking_get, AUTH_NONE)
|
||||||
|
|
|
||||||
58
crud.py
58
crud.py
|
|
@ -12,6 +12,7 @@ from lnbits.db import Database
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
|
OCCUPYING_STATUSES,
|
||||||
Block,
|
Block,
|
||||||
Booking,
|
Booking,
|
||||||
BookingStatus,
|
BookingStatus,
|
||||||
|
|
@ -188,12 +189,9 @@ async def is_available(room_id: str, check_in: str, check_out: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for b in await get_bookings_for_room(room_id):
|
for b in await get_bookings_for_room(room_id):
|
||||||
if b.status in (
|
if b.status in OCCUPYING_STATUSES and _overlaps(
|
||||||
BookingStatus.held,
|
check_in, check_out, b.check_in, b.check_out
|
||||||
BookingStatus.awaiting_payment,
|
):
|
||||||
BookingStatus.confirmed,
|
|
||||||
BookingStatus.checked_in,
|
|
||||||
) and _overlaps(check_in, check_out, b.check_in, b.check_out):
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for blk in await get_blocks_for_room(room_id):
|
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
|
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:
|
def nights_between(check_in: str, check_out: str) -> int:
|
||||||
d_in = date.fromisoformat(check_in)
|
d_in = date.fromisoformat(check_in)
|
||||||
d_out = date.fromisoformat(check_out)
|
d_out = date.fromisoformat(check_out)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ flow runs over relays with no HTTP:
|
||||||
| `chatelet_block_create` | wallet | operator blocks a range |
|
| `chatelet_block_create` | wallet | operator blocks a range |
|
||||||
| `chatelet_room_list_mine` | account | operator's rooms across their wallets |
|
| `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_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_availability` | none | is a range free + a quote |
|
||||||
| `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) |
|
| `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`) |
|
| `chatelet_booking_get` | none | guest reads back their booking (ownership by `sender_pubkey`) |
|
||||||
|
|
|
||||||
21
models.py
21
models.py
|
|
@ -231,6 +231,27 @@ class AvailabilityQuery(BaseModel):
|
||||||
check_out: str # YYYY-MM-DD exclusive
|
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):
|
class AvailabilityResult(BaseModel):
|
||||||
room_id: str
|
room_id: str
|
||||||
check_in: str
|
check_in: str
|
||||||
|
|
|
||||||
44
services.py
44
services.py
|
|
@ -14,7 +14,7 @@ backend failure and surfaces as a generic error over RPC (logged server-side).
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import defaultdict
|
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.core.services import create_invoice
|
||||||
from lnbits.exceptions import InvoiceError
|
from lnbits.exceptions import InvoiceError
|
||||||
|
|
@ -28,7 +28,9 @@ from .models import (
|
||||||
BookingQuote,
|
BookingQuote,
|
||||||
BookingRequestData,
|
BookingRequestData,
|
||||||
BookingStatus,
|
BookingStatus,
|
||||||
|
DateRange,
|
||||||
RoomStatus,
|
RoomStatus,
|
||||||
|
UnavailableRanges,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Per-room lock serializing the availability read + the `held` write, so two
|
# 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)
|
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(
|
async def get_availability(
|
||||||
room_id: str, check_in: str, check_out: str
|
room_id: str, check_in: str, check_out: str
|
||||||
) -> AvailabilityResult:
|
) -> AvailabilityResult:
|
||||||
|
|
|
||||||
133
tests/test_unavailable_ranges.py
Normal file
133
tests/test_unavailable_ranges.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -115,6 +115,16 @@ async def handle_room_get(auth: None, request: NostrRpcRequest) -> dict:
|
||||||
return _public_room(room)
|
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:
|
async def handle_availability(auth: None, request: NostrRpcRequest) -> dict:
|
||||||
body = request.body or {}
|
body = request.body or {}
|
||||||
result = await services.get_availability(
|
result = await services.get_availability(
|
||||||
|
|
|
||||||
17
views_api.py
17
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.
|
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.core.models import WalletTypeInfo
|
||||||
from lnbits.decorators import require_admin_key, require_invoice_key
|
from lnbits.decorators import require_admin_key, require_invoice_key
|
||||||
|
|
||||||
|
|
@ -24,6 +24,7 @@ from .models import (
|
||||||
CreateRoomData,
|
CreateRoomData,
|
||||||
Room,
|
Room,
|
||||||
RoomStatus,
|
RoomStatus,
|
||||||
|
UnavailableRanges,
|
||||||
public_booking_dict,
|
public_booking_dict,
|
||||||
public_room_dict,
|
public_room_dict,
|
||||||
)
|
)
|
||||||
|
|
@ -212,6 +213,20 @@ async def api_public_room(room_id: str) -> dict:
|
||||||
return public_room_dict(room)
|
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) --------------------------------------------
|
# --- availability (public read) --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue