Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
9 changed files with 47 additions and 886 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"id": "chatelet",
|
||||
"version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"name": "Chatelet",
|
||||
"repo": "https://git.atitlan.io/aiolabs/chatelet",
|
||||
"short_description": "Nostr-native room rentals (Airbnb-style) for LNbits",
|
||||
|
|
|
|||
12
models.py
12
models.py
|
|
@ -17,7 +17,6 @@ Design notes carried into the field definitions:
|
|||
arbiter of "is this range open" — Nostr events are requests, not locks.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
|
|
@ -195,17 +194,6 @@ class Block(BaseModel):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def public_room_dict(room: Room) -> dict:
|
||||
"""A Room as public JSON for guests — strips operator-private fields: the
|
||||
wallet id, and the check-in instructions (address/gate code, delivered only
|
||||
in the encrypted post-payment DM). Shared by the HTTP and Nostr-RPC guest
|
||||
doors so neither can leak them."""
|
||||
d = json.loads(room.json())
|
||||
d.pop("wallet", None)
|
||||
d.pop("checkin_instructions", None)
|
||||
return d
|
||||
|
||||
|
||||
class AvailabilityQuery(BaseModel):
|
||||
room_id: str
|
||||
check_in: str # YYYY-MM-DD inclusive
|
||||
|
|
|
|||
|
|
@ -1,247 +1,15 @@
|
|||
// Chatelet operator admin — LNbits page shell (Vue 3 + Quasar 2 UMD, no build).
|
||||
// Gotchas honored: no self-closing tags in the template, `${ }` interpolation,
|
||||
// `:style` bindings for typography. See workspace CLAUDE.md.
|
||||
|
||||
const API = '/chatelet/api/v1'
|
||||
|
||||
// Chatelet operator admin page — placeholder.
|
||||
// Quasar 2 + Vue 3 as UMD globals (no build step). Remember: no
|
||||
// self-closing tags in UMD templates, and use :style bindings (not <style>
|
||||
// blocks) for per-element typography overrides on LNbits pages.
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
delimiters: ['${', '}'],
|
||||
|
||||
mixins: [window.windowMixin],
|
||||
data() {
|
||||
return {
|
||||
tab: 'rooms',
|
||||
|
||||
rooms: [],
|
||||
roomsLoading: false,
|
||||
roomDialog: {show: false, data: {}},
|
||||
currencyOptions: ['sat', 'EUR', 'USD', 'GBP', 'CHF'],
|
||||
frequencyOptions: ['night', 'week', 'month'],
|
||||
|
||||
bookingsRoomId: null,
|
||||
bookings: [],
|
||||
|
||||
blocksRoomId: null,
|
||||
blocks: [],
|
||||
blockForm: {start_date: '', end_date: '', reason: ''},
|
||||
|
||||
settings: {},
|
||||
settingsLoading: false,
|
||||
|
||||
roomsColumns: [
|
||||
{name: 'title', label: 'Room', field: 'title', align: 'left'},
|
||||
{
|
||||
name: 'price', label: 'Price', align: 'left',
|
||||
field: row => `${row.price_amount} ${row.price_currency} / ${row.price_frequency}`
|
||||
},
|
||||
{name: 'max_guests', label: 'Max guests', field: 'max_guests', align: 'center'},
|
||||
{name: 'status', label: 'Status', field: 'status', align: 'center'},
|
||||
{name: 'actions', label: '', field: 'id', align: 'right'}
|
||||
],
|
||||
bookingsColumns: [
|
||||
{name: 'check_in', label: 'Check-in', field: 'check_in', align: 'left'},
|
||||
{name: 'check_out', label: 'Check-out', field: 'check_out', align: 'left'},
|
||||
{name: 'nights', label: 'Nights', field: 'nights', align: 'center'},
|
||||
{name: 'num_guests', label: 'Guests', field: 'num_guests', align: 'center'},
|
||||
{name: 'status', label: 'Status', field: 'status', align: 'left'},
|
||||
{name: 'amount_sat', label: 'Sats', field: 'amount_sat', align: 'right'},
|
||||
{
|
||||
name: 'guest', label: 'Guest', align: 'left',
|
||||
field: row => (row.guest_pubkey || '').slice(0, 12) + '…'
|
||||
}
|
||||
],
|
||||
blocksColumns: [
|
||||
{name: 'start_date', label: 'From', field: 'start_date', align: 'left'},
|
||||
{name: 'end_date', label: 'To (exclusive)', field: 'end_date', align: 'left'},
|
||||
{name: 'reason', label: 'Reason', field: 'reason', align: 'left'},
|
||||
{name: 'actions', label: '', field: 'id', align: 'right'}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
adminkey() {
|
||||
const w = this.g.user.wallets[0]
|
||||
return w && w.adminkey
|
||||
},
|
||||
roomOptions() {
|
||||
return this.rooms.map(r => ({label: r.title, value: r.id}))
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
_err(err, fallback) {
|
||||
const msg = (err && err.response && err.response.data && err.response.data.detail)
|
||||
|| (err && err.message) || fallback
|
||||
Quasar.Notify.create({type: 'negative', message: msg, timeout: 5000})
|
||||
},
|
||||
statusColor(status) {
|
||||
return {
|
||||
active: 'green', inactive: 'grey',
|
||||
held: 'orange', awaiting_payment: 'amber', confirmed: 'green',
|
||||
checked_in: 'teal', completed: 'blue-grey',
|
||||
cancelled: 'red', declined: 'red', expired: 'grey'
|
||||
}[status] || 'grey'
|
||||
},
|
||||
|
||||
// --- rooms ---
|
||||
async getRooms() {
|
||||
this.roomsLoading = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request('GET', `${API}/rooms`, this.adminkey)
|
||||
this.rooms = data
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not load rooms')
|
||||
} finally {
|
||||
this.roomsLoading = false
|
||||
}
|
||||
},
|
||||
newRoom() {
|
||||
this.roomDialog = {
|
||||
show: true,
|
||||
data: {
|
||||
price_currency: 'sat', price_frequency: 'night',
|
||||
max_guests: 2, min_nights: 1, amenities: [], images: []
|
||||
}
|
||||
}
|
||||
},
|
||||
editRoom(room) {
|
||||
this.roomDialog = {show: true, data: JSON.parse(JSON.stringify(room))}
|
||||
},
|
||||
async saveRoom() {
|
||||
const d = this.roomDialog.data
|
||||
const body = {
|
||||
title: d.title,
|
||||
description: d.description || '',
|
||||
price_amount: Number(d.price_amount),
|
||||
price_currency: d.price_currency,
|
||||
price_frequency: d.price_frequency || 'night',
|
||||
max_guests: Number(d.max_guests),
|
||||
min_nights: Number(d.min_nights),
|
||||
amenities: d.amenities || [],
|
||||
location: d.location || '',
|
||||
geohash: d.geohash || '',
|
||||
images: d.images || [],
|
||||
checkin_instructions: d.checkin_instructions || ''
|
||||
}
|
||||
try {
|
||||
if (d.id) {
|
||||
await LNbits.api.request('PUT', `${API}/rooms/${d.id}`, this.adminkey, body)
|
||||
} else {
|
||||
await LNbits.api.request('POST', `${API}/rooms`, this.adminkey, body)
|
||||
}
|
||||
this.roomDialog.show = false
|
||||
Quasar.Notify.create({type: 'positive', message: 'Room saved'})
|
||||
this.getRooms()
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not save room')
|
||||
}
|
||||
},
|
||||
deleteRoom(room) {
|
||||
LNbits.utils
|
||||
.confirmDialog(`Delete room "${room.title}"? Its bookings + blocks go too.`)
|
||||
.onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.request('DELETE', `${API}/rooms/${room.id}`, this.adminkey)
|
||||
this.getRooms()
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not delete room')
|
||||
}
|
||||
})
|
||||
},
|
||||
async setPublished(room, on) {
|
||||
const path = on ? 'publish' : 'unpublish'
|
||||
try {
|
||||
await LNbits.api.request('POST', `${API}/rooms/${room.id}/${path}`, this.adminkey)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: on ? 'Published to relays' : 'Unpublished'
|
||||
})
|
||||
this.getRooms()
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not change publish state')
|
||||
}
|
||||
},
|
||||
|
||||
// --- bookings ---
|
||||
async loadBookings() {
|
||||
if (!this.bookingsRoomId) return
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET', `${API}/rooms/${this.bookingsRoomId}/bookings`, this.adminkey
|
||||
)
|
||||
this.bookings = data
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not load bookings')
|
||||
}
|
||||
},
|
||||
|
||||
// --- blocks ---
|
||||
async loadBlocks() {
|
||||
if (!this.blocksRoomId) return
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET', `${API}/rooms/${this.blocksRoomId}/blocks`, this.adminkey
|
||||
)
|
||||
this.blocks = data
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not load blocks')
|
||||
}
|
||||
},
|
||||
async addBlock() {
|
||||
if (!this.blocksRoomId) return
|
||||
const body = {
|
||||
room_id: this.blocksRoomId,
|
||||
start_date: this.blockForm.start_date,
|
||||
end_date: this.blockForm.end_date,
|
||||
reason: this.blockForm.reason || ''
|
||||
}
|
||||
try {
|
||||
await LNbits.api.request('POST', `${API}/blocks`, this.adminkey, body)
|
||||
this.blockForm = {start_date: '', end_date: '', reason: ''}
|
||||
Quasar.Notify.create({type: 'positive', message: 'Dates blocked'})
|
||||
this.loadBlocks()
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not add block')
|
||||
}
|
||||
},
|
||||
async deleteBlock(block) {
|
||||
try {
|
||||
await LNbits.api.request('DELETE', `${API}/blocks/${block.id}`, this.adminkey)
|
||||
this.loadBlocks()
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not delete block')
|
||||
}
|
||||
},
|
||||
|
||||
// --- settings ---
|
||||
async getSettings() {
|
||||
this.settingsLoading = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request('GET', `${API}/settings`, this.adminkey)
|
||||
this.settings = data
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not load settings')
|
||||
} finally {
|
||||
this.settingsLoading = false
|
||||
}
|
||||
},
|
||||
async saveSettings() {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT', `${API}/settings`, this.adminkey, this.settings
|
||||
)
|
||||
this.settings = data
|
||||
Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
|
||||
} catch (err) {
|
||||
this._err(err, 'Could not save settings')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.getRooms()
|
||||
this.getSettings()
|
||||
}
|
||||
// TODO: wire GET /chatelet/api/v1/rooms, room CRUD, calendar, bookings.
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,257 +1,18 @@
|
|||
{% extends "base.html" %}
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('chatelet/static', path='js/index.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context %}
|
||||
{% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="col">
|
||||
<h4 class="q-my-none">Chatelet — Room Rentals</h4>
|
||||
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
|
||||
Operator admin. The guest booking UI lives in the webapp.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-card>
|
||||
<q-tabs
|
||||
v-model="tab" align="left"
|
||||
active-color="primary" indicator-color="primary" class="text-grey">
|
||||
<q-tab name="rooms" icon="hotel" label="Rooms"></q-tab>
|
||||
<q-tab name="bookings" icon="event_available" label="Bookings"></q-tab>
|
||||
<q-tab name="blocks" icon="event_busy" label="Calendar blocks"></q-tab>
|
||||
<q-tab name="settings" icon="settings" label="Settings"></q-tab>
|
||||
</q-tabs>
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-tab-panels v-model="tab" animated>
|
||||
|
||||
<!-- ROOMS -->
|
||||
<q-tab-panel name="rooms">
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="col"><div class="text-h6">Rooms</div></div>
|
||||
<div class="col-auto">
|
||||
<q-btn color="primary" icon="add" label="Add room" @click="newRoom"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
:rows="rooms" :columns="roomsColumns" row-key="id"
|
||||
:loading="roomsLoading" flat :pagination="{rowsPerPage: 0}"
|
||||
no-data-label="No rooms yet — add one to get started">
|
||||
<template v-slot:body-cell-status="props">
|
||||
<q-td :props="props">
|
||||
<q-chip :color="statusColor(props.value)" text-color="white" dense square>
|
||||
${ props.value }
|
||||
</q-chip>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-actions="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
<q-btn
|
||||
v-if="props.row.status !== 'active'"
|
||||
flat dense round icon="cloud_upload" color="green"
|
||||
@click="setPublished(props.row, true)">
|
||||
<q-tooltip>Publish to relays</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-else flat dense round icon="cloud_off" color="grey"
|
||||
@click="setPublished(props.row, false)">
|
||||
<q-tooltip>Unpublish</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat dense round icon="edit" color="primary"
|
||||
@click="editRoom(props.row)">
|
||||
<q-tooltip>Edit</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat dense round icon="delete" color="negative"
|
||||
@click="deleteRoom(props.row)">
|
||||
<q-tooltip>Delete</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-tab-panel>
|
||||
|
||||
<!-- BOOKINGS -->
|
||||
<q-tab-panel name="bookings">
|
||||
<div class="row items-center q-mb-md q-gutter-sm">
|
||||
<q-select
|
||||
style="min-width: 260px" outlined dense
|
||||
v-model="bookingsRoomId" :options="roomOptions"
|
||||
emit-value map-options label="Room"
|
||||
@update:model-value="loadBookings"></q-select>
|
||||
<q-btn flat dense icon="refresh" @click="loadBookings"
|
||||
:disable="!bookingsRoomId">
|
||||
<q-tooltip>Refresh</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<q-table
|
||||
:rows="bookings" :columns="bookingsColumns" row-key="id" flat
|
||||
:pagination="{rowsPerPage: 0}"
|
||||
no-data-label="Pick a room to see its bookings">
|
||||
<template v-slot:body-cell-status="props">
|
||||
<q-td :props="props">
|
||||
<q-chip :color="statusColor(props.value)" text-color="white" dense square>
|
||||
${ props.value }
|
||||
</q-chip>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-tab-panel>
|
||||
|
||||
<!-- CALENDAR BLOCKS -->
|
||||
<q-tab-panel name="blocks">
|
||||
<div class="row items-center q-mb-md q-gutter-sm">
|
||||
<q-select
|
||||
style="min-width: 260px" outlined dense
|
||||
v-model="blocksRoomId" :options="roomOptions"
|
||||
emit-value map-options label="Room"
|
||||
@update:model-value="loadBlocks"></q-select>
|
||||
<q-btn flat dense icon="refresh" @click="loadBlocks"
|
||||
:disable="!blocksRoomId">
|
||||
<q-tooltip>Refresh</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div v-if="blocksRoomId" class="row q-col-gutter-sm q-mb-md items-end">
|
||||
<div class="col-auto">
|
||||
<q-input outlined dense type="date" v-model="blockForm.start_date"
|
||||
label="From"></q-input>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-input outlined dense type="date" v-model="blockForm.end_date"
|
||||
label="To (exclusive)"></q-input>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-input outlined dense v-model="blockForm.reason"
|
||||
label="Reason (optional)"></q-input>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn color="primary" icon="add" label="Block" @click="addBlock"
|
||||
:disable="!blockForm.start_date || !blockForm.end_date"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
:rows="blocks" :columns="blocksColumns" row-key="id" flat
|
||||
:pagination="{rowsPerPage: 0}"
|
||||
no-data-label="No manual blocks for this room">
|
||||
<template v-slot:body-cell-actions="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
<q-btn flat dense round icon="delete" color="negative"
|
||||
@click="deleteBlock(props.row)"></q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-tab-panel>
|
||||
|
||||
<!-- SETTINGS -->
|
||||
<q-tab-panel name="settings">
|
||||
<div class="text-h6 q-mb-md">Operator settings</div>
|
||||
<div class="row q-col-gutter-md" style="max-width: 760px">
|
||||
<div class="col-12">
|
||||
<q-input outlined dense v-model="settings.operator_id"
|
||||
label="Operator account id"
|
||||
hint="LNbits account whose Nostr signer publishes listings + check-in DMs (bunker-backed for the encrypted flows)"></q-input>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-select outlined dense v-model="settings.relays" label="Relays"
|
||||
use-chips multiple use-input hide-dropdown-icon
|
||||
new-value-mode="add-unique"
|
||||
hint="Relays where listings + availability are published"></q-select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<q-input outlined dense type="number" label="Hold minutes"
|
||||
v-model.number="settings.default_hold_minutes"></q-input>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<q-input outlined dense type="number" label="Deposit %"
|
||||
v-model.number="settings.deposit_percent"></q-input>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<q-input outlined dense label="Check-in time"
|
||||
v-model="settings.checkin_time"></q-input>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<q-input outlined dense label="Check-out time"
|
||||
v-model="settings.checkout_time"></q-input>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-input outlined dense type="textarea" autogrow
|
||||
label="Cancellation policy"
|
||||
v-model="settings.cancellation_policy"></q-input>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-toggle v-model="settings.publish_availability"
|
||||
label="Publish blocked dates to a public calendar (kind:31923)"></q-toggle>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-btn color="primary" label="Save settings" @click="saveSettings"
|
||||
:loading="settingsLoading"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
|
||||
</q-tab-panels>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-my-none">Chatelet — Room Rentals</h5>
|
||||
<p class="text-caption">
|
||||
Nostr-native room rentals. Operator admin UI goes here (rooms,
|
||||
calendar, bookings). Guest booking UI lives in the webapp.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ROOM CREATE / EDIT DIALOG -->
|
||||
<q-dialog v-model="roomDialog.show">
|
||||
<q-card style="min-width: 480px; max-width: 640px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">${ roomDialog.data.id ? 'Edit room' : 'New room' }</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-gutter-sm scroll" style="max-height: 70vh">
|
||||
<q-input outlined dense v-model="roomDialog.data.title" label="Title *"></q-input>
|
||||
<q-input outlined dense type="textarea" autogrow
|
||||
v-model="roomDialog.data.description" label="Description (markdown)"></q-input>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-4">
|
||||
<q-input outlined dense type="number" label="Price *"
|
||||
v-model.number="roomDialog.data.price_amount"></q-input>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<q-select outlined dense label="Currency" :options="currencyOptions"
|
||||
v-model="roomDialog.data.price_currency"></q-select>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<q-select outlined dense label="Per" :options="frequencyOptions"
|
||||
v-model="roomDialog.data.price_frequency"></q-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-6">
|
||||
<q-input outlined dense type="number" label="Max guests"
|
||||
v-model.number="roomDialog.data.max_guests"></q-input>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-input outlined dense type="number" label="Min nights"
|
||||
v-model.number="roomDialog.data.min_nights"></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-input outlined dense v-model="roomDialog.data.location" label="Location"></q-input>
|
||||
<q-input outlined dense v-model="roomDialog.data.geohash"
|
||||
label="Geohash (optional)"></q-input>
|
||||
<q-select outlined dense v-model="roomDialog.data.amenities" label="Amenities"
|
||||
use-chips multiple use-input hide-dropdown-icon
|
||||
new-value-mode="add-unique"></q-select>
|
||||
<q-select outlined dense v-model="roomDialog.data.images" label="Image URLs"
|
||||
use-chips multiple use-input hide-dropdown-icon
|
||||
new-value-mode="add-unique"></q-select>
|
||||
<q-input outlined dense type="textarea" autogrow
|
||||
v-model="roomDialog.data.checkin_instructions"
|
||||
label="Check-in instructions (private — sent only in the encrypted DM)"></q-input>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
||||
<q-btn color="primary" label="Save" @click="saveRoom"
|
||||
:disable="!roomDialog.data.title || roomDialog.data.price_amount == null"></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('chatelet/static', 'js/index.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
"""Operator admin endpoint logic (backs the admin UI). Calls the view
|
||||
functions directly with a fake auth key (bypassing FastAPI's Depends) and
|
||||
monkeypatched crud — no live server."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .. import crud, views_api
|
||||
from ..models import ChateletSettings, CreateRoomData, RoomStatus
|
||||
from .conftest import make_room
|
||||
|
||||
|
||||
def _key(wallet_id: str = "w1"):
|
||||
return SimpleNamespace(wallet=SimpleNamespace(id=wallet_id, user="u1"))
|
||||
|
||||
|
||||
def test_owned_room_rejects_other_wallet(monkeypatch):
|
||||
async def gr(_):
|
||||
return make_room(wallet="w1")
|
||||
|
||||
monkeypatch.setattr(crud, "get_room", gr)
|
||||
with pytest.raises(HTTPException) as e:
|
||||
asyncio.run(views_api._owned_room("room1", _key("w2")))
|
||||
assert e.value.status_code == 403
|
||||
|
||||
|
||||
def test_owned_room_404_when_missing(monkeypatch):
|
||||
async def gr(_):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(crud, "get_room", gr)
|
||||
with pytest.raises(HTTPException) as e:
|
||||
asyncio.run(views_api._owned_room("nope", _key()))
|
||||
assert e.value.status_code == 404
|
||||
|
||||
|
||||
def test_list_rooms_filters_to_calling_wallet(monkeypatch):
|
||||
async def gr():
|
||||
return [make_room("a", wallet="w1"), make_room("b", wallet="w2")]
|
||||
|
||||
monkeypatch.setattr(crud, "get_rooms", gr)
|
||||
out = asyncio.run(views_api.api_list_rooms(key=_key("w1")))
|
||||
assert [r.id for r in out] == ["a"]
|
||||
|
||||
|
||||
def test_update_room_applies_mutable_fields_only(monkeypatch):
|
||||
room = make_room("room1", wallet="w1", status=RoomStatus.inactive)
|
||||
|
||||
async def gr(_):
|
||||
return room
|
||||
|
||||
async def ur(r):
|
||||
return r
|
||||
|
||||
monkeypatch.setattr(crud, "get_room", gr)
|
||||
monkeypatch.setattr(crud, "update_room", ur)
|
||||
|
||||
data = CreateRoomData(
|
||||
wallet="attacker", title="New Title", price_amount=200,
|
||||
price_currency="USD", max_guests=5, checkin_instructions="gate 7",
|
||||
)
|
||||
out = asyncio.run(views_api.api_update_room("room1", data, key=_key("w1")))
|
||||
assert out.title == "New Title"
|
||||
assert out.price_amount == 200
|
||||
assert out.checkin_instructions == "gate 7"
|
||||
assert out.wallet == "w1" # wallet is NOT client-mutable
|
||||
assert out.id == "room1" # id unchanged
|
||||
|
||||
|
||||
def test_update_settings_merges_editable_fields(monkeypatch):
|
||||
existing = ChateletSettings(operator_id=None, deposit_percent=100)
|
||||
|
||||
async def gs():
|
||||
return existing
|
||||
|
||||
async def us(s):
|
||||
return s
|
||||
|
||||
monkeypatch.setattr(crud, "get_or_create_settings", gs)
|
||||
monkeypatch.setattr(crud, "update_settings", us)
|
||||
|
||||
incoming = ChateletSettings(
|
||||
operator_id="op1", deposit_percent=50, default_hold_minutes=45,
|
||||
relays=["wss://relay.example"],
|
||||
)
|
||||
out = asyncio.run(views_api.api_update_settings(incoming, key=_key()))
|
||||
assert out.operator_id == "op1"
|
||||
assert out.deposit_percent == 50
|
||||
assert out.default_hold_minutes == 45
|
||||
assert out.relays == ["wss://relay.example"]
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
"""Real-DB migration test (#14).
|
||||
|
||||
Runs the full migration chain against a fresh temp SQLite via the lnbits
|
||||
`Database`, then round-trips through `crud` to prove the resulting schema.
|
||||
Guards the class of bug from #13 (a migration statement that parses in Python
|
||||
but is invalid SQL on SQLite — the default backend). The rest of the suite
|
||||
monkeypatches `crud`, so migrations are otherwise never actually executed.
|
||||
|
||||
Isolation: `Database` binds its sqlite path + engine at construction from
|
||||
`settings.lnbits_data_folder`, and `crud.db` is built at import time. So we
|
||||
point settings at `tmp_path`, build a fresh `ext_chatelet` DB, and swap it
|
||||
into `crud` for the test — import-order-independent, no live server.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .. import crud, migrations
|
||||
from ..models import Booking, BookingStatus, CreateRoomData, RoomStatus
|
||||
|
||||
|
||||
def test_full_migration_chain_applies_and_schema_round_trips(monkeypatch, tmp_path):
|
||||
# Fresh, isolated ext DB in tmp_path; route crud at it for the test.
|
||||
monkeypatch.setattr(settings, "lnbits_data_folder", str(tmp_path))
|
||||
test_db = Database("ext_chatelet")
|
||||
monkeypatch.setattr(crud, "db", test_db)
|
||||
|
||||
async def run():
|
||||
# Apply every m0NN migration in order against the empty DB. If any
|
||||
# statement is invalid on SQLite (the #13 bug), this raises here.
|
||||
migfns = sorted(
|
||||
(n, f) for n, f in vars(migrations).items() if re.match(r"m\d+_", n)
|
||||
)
|
||||
assert migfns, "no m0NN migrations discovered"
|
||||
async with test_db.connect() as conn:
|
||||
for _name, fn in migfns:
|
||||
await fn(conn)
|
||||
|
||||
# Round-trip through crud (now bound to test_db) to prove the schema.
|
||||
room = await crud.create_room(
|
||||
CreateRoomData(
|
||||
wallet="w1", title="Keep", price_amount=90, price_currency="EUR"
|
||||
)
|
||||
)
|
||||
got = await crud.get_room(room.id)
|
||||
assert got is not None
|
||||
assert got.checkin_instructions == "" # m002 column exists, default ''
|
||||
got.status = RoomStatus.active
|
||||
await crud.update_room(got)
|
||||
|
||||
booking = Booking(
|
||||
id="bk1", room_id=room.id, guest_pubkey="g",
|
||||
check_in="2026-08-01", check_out="2026-08-04", nights=3, num_guests=1,
|
||||
currency="EUR", price_fiat=270.0, amount_sat=450000, deposit_sat=450000,
|
||||
status=BookingStatus.held,
|
||||
)
|
||||
await crud.create_booking(booking)
|
||||
gb = await crud.get_booking("bk1")
|
||||
assert gb is not None and gb.amount_sat == 450000 # big_int round-trips
|
||||
|
||||
# Availability computed against real rows (not monkeypatched): the held
|
||||
# booking blocks its own dates; a non-overlapping range is free.
|
||||
assert await crud.is_available(room.id, "2026-08-01", "2026-08-04") is False
|
||||
assert await crud.is_available(room.id, "2026-08-10", "2026-08-12") is True
|
||||
|
||||
asyncio.run(run())
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
"""Public guest discovery endpoints + the operator-private field strip
|
||||
(privacy: check-in instructions must never reach a guest)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .. import crud, views_api
|
||||
from ..models import RoomStatus, public_room_dict
|
||||
from .conftest import make_room
|
||||
|
||||
|
||||
def test_public_room_dict_strips_private_fields():
|
||||
room = make_room(wallet="w1")
|
||||
room.checkin_instructions = "gate code 4213, door on the left"
|
||||
d = public_room_dict(room)
|
||||
assert "wallet" not in d # operator-internal
|
||||
assert "checkin_instructions" not in d # private, DM-only
|
||||
assert d["title"] == "Tower Room" # public fields survive
|
||||
assert d["price_amount"] == 100
|
||||
|
||||
|
||||
def test_public_rooms_lists_active_only_and_stripped(monkeypatch):
|
||||
active = make_room("a", status=RoomStatus.active)
|
||||
active.checkin_instructions = "secret"
|
||||
inactive = make_room("b", status=RoomStatus.inactive)
|
||||
|
||||
async def gr():
|
||||
return [active, inactive]
|
||||
|
||||
monkeypatch.setattr(crud, "get_rooms", gr)
|
||||
out = asyncio.run(views_api.api_public_rooms())
|
||||
assert [r["id"] for r in out] == ["a"] # inactive hidden from guests
|
||||
assert "checkin_instructions" not in out[0]
|
||||
assert "wallet" not in out[0]
|
||||
|
||||
|
||||
def test_public_room_404_when_inactive(monkeypatch):
|
||||
async def gr(_):
|
||||
return make_room(status=RoomStatus.inactive)
|
||||
|
||||
monkeypatch.setattr(crud, "get_room", gr)
|
||||
with pytest.raises(HTTPException) as e:
|
||||
asyncio.run(views_api.api_public_room("x"))
|
||||
assert e.value.status_code == 404
|
||||
|
||||
|
||||
def test_public_room_returns_stripped_when_active(monkeypatch):
|
||||
room = make_room("a", status=RoomStatus.active)
|
||||
room.checkin_instructions = "gate"
|
||||
|
||||
async def gr(_):
|
||||
return room
|
||||
|
||||
monkeypatch.setattr(crud, "get_room", gr)
|
||||
out = asyncio.run(views_api.api_public_room("a"))
|
||||
assert out["id"] == "a"
|
||||
assert "checkin_instructions" not in out
|
||||
assert "wallet" not in out
|
||||
|
|
@ -196,9 +196,6 @@ def _to_dict(obj) -> dict:
|
|||
|
||||
|
||||
def _public_room(room) -> dict:
|
||||
# Shared with the HTTP door; strips wallet id AND checkin_instructions
|
||||
# (the latter was leaking to guests before — added after this file's
|
||||
# original public dict).
|
||||
from .models import public_room_dict
|
||||
|
||||
return public_room_dict(room)
|
||||
d = _to_dict(room)
|
||||
d.pop("wallet", None) # wallet id is operator-internal, not for guests
|
||||
return d
|
||||
|
|
|
|||
185
views_api.py
185
views_api.py
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
The REST surface and the Nostr-transport surface (transport_rpcs.py) are two
|
||||
doors into the SAME booking flow — both delegate to services.py so
|
||||
availability arbitration + quoting live in one place. The operator admin
|
||||
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.
|
||||
availability arbitration + quoting live in one place. Per the aiolabs
|
||||
long-term direction (webapp<->lnbits over Nostr), REST is the transitional
|
||||
door; keep new booking logic in services.py, not here.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
|
@ -15,36 +15,17 @@ from . import crud, services
|
|||
from .models import (
|
||||
AvailabilityQuery,
|
||||
AvailabilityResult,
|
||||
Block,
|
||||
Booking,
|
||||
BookingQuote,
|
||||
BookingRequestData,
|
||||
ChateletSettings,
|
||||
CreateBlockData,
|
||||
CreateRoomData,
|
||||
Room,
|
||||
RoomStatus,
|
||||
public_room_dict,
|
||||
)
|
||||
from .nostr import service as nostr
|
||||
|
||||
chatelet_api_router = APIRouter()
|
||||
|
||||
# Fields patchable via PUT /rooms/{id}. Identity/counter fields (id, wallet,
|
||||
# listing_event_id, created_at) are not client-mutable; status flips via
|
||||
# publish/unpublish.
|
||||
_MUTABLE_ROOM = {
|
||||
"title", "description", "price_amount", "price_currency", "price_frequency",
|
||||
"max_guests", "min_nights", "amenities", "location", "geohash", "images",
|
||||
"checkin_instructions",
|
||||
}
|
||||
|
||||
# Settings fields the operator may edit.
|
||||
_EDITABLE_SETTINGS = (
|
||||
"operator_id", "relays", "default_hold_minutes", "deposit_percent",
|
||||
"checkin_time", "checkout_time", "cancellation_policy", "publish_availability",
|
||||
)
|
||||
|
||||
|
||||
def _to_http(exc: ValueError) -> HTTPException:
|
||||
"""Map a services-layer ValueError subclass to an HTTP status."""
|
||||
|
|
@ -55,162 +36,34 @@ def _to_http(exc: ValueError) -> HTTPException:
|
|||
return HTTPException(400, str(exc))
|
||||
|
||||
|
||||
async def _owned_room(room_id: str, key: WalletTypeInfo) -> Room:
|
||||
room = await crud.get_room(room_id)
|
||||
if not room:
|
||||
raise HTTPException(404, "Room not found")
|
||||
if room.wallet != key.wallet.id:
|
||||
raise HTTPException(403, "Room does not belong to this wallet")
|
||||
return room
|
||||
|
||||
|
||||
# --- rooms (operator; admin-key scoped to own wallet) ----------------------
|
||||
|
||||
|
||||
@chatelet_api_router.get("/api/v1/rooms")
|
||||
async def api_list_rooms(
|
||||
key: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> list[Room]:
|
||||
return [r for r in await crud.get_rooms() if r.wallet == key.wallet.id]
|
||||
|
||||
|
||||
@chatelet_api_router.post("/api/v1/rooms", status_code=201)
|
||||
async def api_create_room(
|
||||
data: CreateRoomData, key: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> Room:
|
||||
data.wallet = key.wallet.id # rooms are owned by the calling wallet
|
||||
data.wallet = data.wallet or key.wallet.id
|
||||
return await crud.create_room(data)
|
||||
|
||||
|
||||
@chatelet_api_router.put("/api/v1/rooms/{room_id}")
|
||||
async def api_update_room(
|
||||
room_id: str,
|
||||
data: CreateRoomData,
|
||||
key: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Room:
|
||||
room = await _owned_room(room_id, key)
|
||||
for field in _MUTABLE_ROOM:
|
||||
setattr(room, field, getattr(data, field))
|
||||
room = await crud.update_room(room)
|
||||
if room.status == RoomStatus.active:
|
||||
# keep the published listing in sync with the edit
|
||||
room.listing_event_id = (
|
||||
await nostr.publish_listing(room) or room.listing_event_id
|
||||
)
|
||||
room = await crud.update_room(room)
|
||||
return room
|
||||
|
||||
|
||||
@chatelet_api_router.delete("/api/v1/rooms/{room_id}")
|
||||
async def api_delete_room(
|
||||
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> dict:
|
||||
await _owned_room(room_id, key)
|
||||
await crud.delete_room(room_id)
|
||||
return {"deleted": True}
|
||||
@chatelet_api_router.get("/api/v1/rooms")
|
||||
async def api_list_rooms() -> list[Room]:
|
||||
return await crud.get_rooms()
|
||||
|
||||
|
||||
@chatelet_api_router.post("/api/v1/rooms/{room_id}/publish")
|
||||
async def api_publish_room(
|
||||
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> Room:
|
||||
room = await _owned_room(room_id, key)
|
||||
room.status = RoomStatus.active
|
||||
room = await crud.get_room(room_id)
|
||||
if not room:
|
||||
raise HTTPException(404, "Room not found")
|
||||
room.status = room.status.active
|
||||
room.listing_event_id = await nostr.publish_listing(room) or room.listing_event_id
|
||||
return await crud.update_room(room)
|
||||
|
||||
|
||||
@chatelet_api_router.post("/api/v1/rooms/{room_id}/unpublish")
|
||||
async def api_unpublish_room(
|
||||
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> Room:
|
||||
room = await _owned_room(room_id, key)
|
||||
room.status = RoomStatus.inactive
|
||||
return await crud.update_room(room)
|
||||
|
||||
|
||||
@chatelet_api_router.get("/api/v1/rooms/{room_id}/bookings")
|
||||
async def api_room_bookings(
|
||||
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> list[Booking]:
|
||||
await _owned_room(room_id, key)
|
||||
return await crud.get_bookings_for_room(room_id)
|
||||
|
||||
|
||||
@chatelet_api_router.get("/api/v1/rooms/{room_id}/blocks")
|
||||
async def api_room_blocks(
|
||||
room_id: str, key: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> list[Block]:
|
||||
await _owned_room(room_id, key)
|
||||
return await crud.get_blocks_for_room(room_id)
|
||||
|
||||
|
||||
# --- blocks (operator) -----------------------------------------------------
|
||||
|
||||
|
||||
@chatelet_api_router.post("/api/v1/blocks", status_code=201)
|
||||
async def api_create_block(
|
||||
data: CreateBlockData, key: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> Block:
|
||||
await _owned_room(data.room_id, key)
|
||||
block = await crud.create_block(data)
|
||||
room = await crud.get_room(data.room_id)
|
||||
if room:
|
||||
await nostr.publish_block_calendar(
|
||||
room, block.start_date, block.end_date, block.id
|
||||
)
|
||||
return block
|
||||
|
||||
|
||||
@chatelet_api_router.delete("/api/v1/blocks/{block_id}")
|
||||
async def api_delete_block(
|
||||
block_id: str, key: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> dict:
|
||||
await crud.delete_block(block_id)
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
# --- settings (operator) ---------------------------------------------------
|
||||
|
||||
|
||||
@chatelet_api_router.get("/api/v1/settings")
|
||||
async def api_get_settings(
|
||||
key: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> ChateletSettings:
|
||||
return await crud.get_or_create_settings()
|
||||
|
||||
|
||||
@chatelet_api_router.put("/api/v1/settings")
|
||||
async def api_update_settings(
|
||||
data: ChateletSettings, key: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> ChateletSettings:
|
||||
settings = await crud.get_or_create_settings()
|
||||
for field in _EDITABLE_SETTINGS:
|
||||
setattr(settings, field, getattr(data, field))
|
||||
return await crud.update_settings(settings)
|
||||
|
||||
|
||||
# --- public guest discovery (no auth) --------------------------------------
|
||||
|
||||
|
||||
@chatelet_api_router.get("/api/v1/public/rooms")
|
||||
async def api_public_rooms() -> list[dict]:
|
||||
"""Active rooms for guest browsing — operator-private fields stripped."""
|
||||
return [
|
||||
public_room_dict(r)
|
||||
for r in await crud.get_rooms()
|
||||
if r.status == RoomStatus.active
|
||||
]
|
||||
|
||||
|
||||
@chatelet_api_router.get("/api/v1/public/rooms/{room_id}")
|
||||
async def api_public_room(room_id: str) -> dict:
|
||||
room = await crud.get_room(room_id)
|
||||
if not room or room.status != RoomStatus.active:
|
||||
raise HTTPException(404, "Room not available")
|
||||
return public_room_dict(room)
|
||||
|
||||
|
||||
# --- availability (public read) --------------------------------------------
|
||||
|
||||
|
||||
|
|
@ -243,3 +96,19 @@ async def api_get_booking(
|
|||
if not booking:
|
||||
raise HTTPException(404, "Booking not found")
|
||||
return booking
|
||||
|
||||
|
||||
# --- blocks (operator) -----------------------------------------------------
|
||||
|
||||
|
||||
@chatelet_api_router.post("/api/v1/blocks", status_code=201)
|
||||
async def api_create_block(
|
||||
data: CreateBlockData, key: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
block = await crud.create_block(data)
|
||||
room = await crud.get_room(data.room_id)
|
||||
if room:
|
||||
await nostr.publish_block_calendar(
|
||||
room, block.start_date, block.end_date, block.id
|
||||
)
|
||||
return block
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue