feat: CRUD + availability arbiter
Room/booking/block persistence, and is_available() — the authority for whether a range is free: room active AND no occupying booking or block overlaps [check_in, check_out) (half-open, so back-to-back stays are fine). expire_stale_holds() frees dates for lapsed unpaid holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
This commit is contained in:
parent
95e5bcb136
commit
e7fabd57bf
1 changed files with 227 additions and 0 deletions
227
crud.py
Normal file
227
crud.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"""Chatelet CRUD + the availability arbiter.
|
||||
|
||||
The DB is the single source of truth for whether a range is free. Nostr
|
||||
booking requests are just requests; `is_available` + the `held` write is
|
||||
where a date range actually gets locked. Keep the check-then-hold path
|
||||
tight so two concurrent requests can't both win the same nights.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from .models import (
|
||||
Block,
|
||||
Booking,
|
||||
BookingStatus,
|
||||
ChateletSettings,
|
||||
CreateBlockData,
|
||||
CreateRoomData,
|
||||
Room,
|
||||
RoomStatus,
|
||||
)
|
||||
|
||||
db = Database("ext_chatelet")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_or_create_settings() -> ChateletSettings:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM chatelet.settings LIMIT 1", model=ChateletSettings
|
||||
)
|
||||
if row:
|
||||
return row
|
||||
settings = ChateletSettings()
|
||||
await db.insert("chatelet.settings", settings)
|
||||
return settings
|
||||
|
||||
|
||||
async def update_settings(settings: ChateletSettings) -> ChateletSettings:
|
||||
settings.updated_at = datetime.now(timezone.utc)
|
||||
await db.update("chatelet.settings", settings, "")
|
||||
return settings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rooms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def create_room(data: CreateRoomData) -> Room:
|
||||
assert data.wallet, "wallet is required"
|
||||
room = Room(
|
||||
id=urlsafe_short_hash()[:8],
|
||||
wallet=data.wallet,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
price_amount=data.price_amount,
|
||||
price_currency=data.price_currency,
|
||||
price_frequency=data.price_frequency,
|
||||
max_guests=data.max_guests,
|
||||
min_nights=data.min_nights,
|
||||
amenities=data.amenities,
|
||||
location=data.location,
|
||||
geohash=data.geohash,
|
||||
images=data.images,
|
||||
status=RoomStatus.inactive,
|
||||
)
|
||||
await db.insert("chatelet.rooms", room)
|
||||
return room
|
||||
|
||||
|
||||
async def get_room(room_id: str) -> Room | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM chatelet.rooms WHERE id = :id", {"id": room_id}, Room
|
||||
)
|
||||
|
||||
|
||||
async def get_rooms() -> list[Room]:
|
||||
return await db.fetchall("SELECT * FROM chatelet.rooms", model=Room)
|
||||
|
||||
|
||||
async def update_room(room: Room) -> Room:
|
||||
room.updated_at = datetime.now(timezone.utc)
|
||||
await db.update("chatelet.rooms", room)
|
||||
return room
|
||||
|
||||
|
||||
async def delete_room(room_id: str) -> None:
|
||||
await db.execute("DELETE FROM chatelet.rooms WHERE id = :id", {"id": room_id})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bookings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_booking(booking_id: str) -> Booking | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM chatelet.bookings WHERE id = :id", {"id": booking_id}, Booking
|
||||
)
|
||||
|
||||
|
||||
async def get_booking_by_payment_hash(payment_hash: str) -> Booking | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM chatelet.bookings WHERE payment_hash = :ph",
|
||||
{"ph": payment_hash},
|
||||
Booking,
|
||||
)
|
||||
|
||||
|
||||
async def get_bookings_for_room(room_id: str) -> list[Booking]:
|
||||
return await db.fetchall(
|
||||
"SELECT * FROM chatelet.bookings WHERE room_id = :rid",
|
||||
{"rid": room_id},
|
||||
Booking,
|
||||
)
|
||||
|
||||
|
||||
async def create_booking(booking: Booking) -> Booking:
|
||||
await db.insert("chatelet.bookings", booking)
|
||||
return booking
|
||||
|
||||
|
||||
async def update_booking(booking: Booking) -> Booking:
|
||||
booking.updated_at = datetime.now(timezone.utc)
|
||||
await db.update("chatelet.bookings", booking)
|
||||
return booking
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blocks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def create_block(data: CreateBlockData) -> Block:
|
||||
block = Block(
|
||||
id=urlsafe_short_hash()[:8],
|
||||
room_id=data.room_id,
|
||||
start_date=data.start_date,
|
||||
end_date=data.end_date,
|
||||
reason=data.reason,
|
||||
)
|
||||
await db.insert("chatelet.blocks", block)
|
||||
return block
|
||||
|
||||
|
||||
async def get_blocks_for_room(room_id: str) -> list[Block]:
|
||||
return await db.fetchall(
|
||||
"SELECT * FROM chatelet.blocks WHERE room_id = :rid", {"rid": room_id}, Block
|
||||
)
|
||||
|
||||
|
||||
async def delete_block(block_id: str) -> None:
|
||||
await db.execute("DELETE FROM chatelet.blocks WHERE id = :id", {"id": block_id})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Availability — the arbiter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _overlaps(a_start: str, a_end: str, b_start: str, b_end: str) -> bool:
|
||||
"""Half-open interval overlap: [a_start, a_end) ∩ [b_start, b_end).
|
||||
|
||||
check_out / end_date are exclusive, so a stay ending on the same day
|
||||
another begins does NOT overlap (back-to-back bookings are fine).
|
||||
"""
|
||||
return a_start < b_end and b_start < a_end
|
||||
|
||||
|
||||
async def is_available(room_id: str, check_in: str, check_out: str) -> bool:
|
||||
"""True iff the room is active and no occupying booking or block
|
||||
overlaps [check_in, check_out). This is the authoritative check; call
|
||||
it inside the same request path that writes the `held` booking.
|
||||
|
||||
NOTE: for production, wrap the check+hold in a transaction (or a
|
||||
per-room asyncio lock) so two simultaneous requests can't both pass
|
||||
the read before either writes. See docs/event-flow.md § Concurrency.
|
||||
"""
|
||||
room = await get_room(room_id)
|
||||
if not room or room.status != RoomStatus.active:
|
||||
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):
|
||||
return False
|
||||
|
||||
for blk in await get_blocks_for_room(room_id):
|
||||
if _overlaps(check_in, check_out, blk.start_date, blk.end_date):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def nights_between(check_in: str, check_out: str) -> int:
|
||||
d_in = date.fromisoformat(check_in)
|
||||
d_out = date.fromisoformat(check_out)
|
||||
return (d_out - d_in).days
|
||||
|
||||
|
||||
async def expire_stale_holds() -> list[Booking]:
|
||||
"""Flip `held`/`awaiting_payment` bookings whose hold has lapsed to
|
||||
`expired`, freeing their dates. Driven by tasks.py on a timer."""
|
||||
now = datetime.now(timezone.utc)
|
||||
stale = await db.fetchall(
|
||||
"""
|
||||
SELECT * FROM chatelet.bookings
|
||||
WHERE status IN ('held', 'awaiting_payment')
|
||||
AND expires_at IS NOT NULL AND expires_at < :now
|
||||
""",
|
||||
{"now": now},
|
||||
Booking,
|
||||
)
|
||||
for b in stale:
|
||||
b.status = BookingStatus.expired
|
||||
await update_booking(b)
|
||||
return stale
|
||||
Loading…
Add table
Add a link
Reference in a new issue