Compare commits
No commits in common. "b5b5141ea4aeec33169960a508ebbb3990cd9401" and "21aa6ea7c380be030395a55c910d5b935aeadf10" have entirely different histories.
b5b5141ea4
...
21aa6ea7c3
8 changed files with 8 additions and 278 deletions
|
|
@ -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)
|
||||
|
|
|
|||
58
crud.py
58
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)
|
||||
|
|
|
|||
|
|
@ -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`) |
|
||||
|
|
|
|||
21
models.py
21
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
|
||||
|
|
|
|||
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
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
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.
|
||||
"""
|
||||
|
||||
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) --------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue