feat: support optional start/end time on events
event_start_date / event_end_date now accept either YYYY-MM-DD (date-only) or YYYY-MM-DDTHH:MM (ISO datetime). The NIP-52 publisher switches kind on the "T" delimiter: kind 31922 (date-based, YYYY-MM-DD start/end) when absent, kind 31923 (time-based, unix-timestamp start/end + day-granularity D tags) when present. Delete events match the original publish kind. Closing-date parsing accepts both formats. The LNbits admin form gains optional HH:MM inputs alongside each date picker; they fold into the wire-format string on submit and split back on edit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6aa280680e
commit
df4775126f
5 changed files with 138 additions and 32 deletions
|
|
@ -34,9 +34,11 @@ class CreateEvent(BaseModel):
|
||||||
wallet: str | None = None # filled from caller's wallet if absent
|
wallet: str | None = None # filled from caller's wallet if absent
|
||||||
name: str # title (required)
|
name: str # title (required)
|
||||||
info: str = "" # description (optional)
|
info: str = "" # description (optional)
|
||||||
closing_date: str | None = None # defaults to event_end_date
|
closing_date: str | None = None # date-only YYYY-MM-DD; defaults to event_end_date
|
||||||
event_start_date: str # required
|
# ISO 8601: date-only ("2026-05-19") or datetime ("2026-05-19T18:30").
|
||||||
event_end_date: str | None = None # defaults to event_start_date
|
# Presence of a "T" toggles NIP-52 kind (31922 date / 31923 time).
|
||||||
|
event_start_date: str
|
||||||
|
event_end_date: str | None = None # same format as event_start_date
|
||||||
currency: str = "sat"
|
currency: str = "sat"
|
||||||
allow_fiat: bool = False
|
allow_fiat: bool = False
|
||||||
fiat_currency: str = "GBP"
|
fiat_currency: str = "GBP"
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
"""
|
"""
|
||||||
NIP-52 calendar event publishing for the events extension.
|
NIP-52 calendar event publishing for the events extension.
|
||||||
|
|
||||||
Builds kind 31922 (date-based) calendar events from the Event model,
|
Builds NIP-52 calendar events from the Event model, signs them with the
|
||||||
signs them with the event creator's Account keypair, and publishes
|
creator's Account keypair, and publishes via the NostrClient.
|
||||||
via the NostrClient to nostrclient relays.
|
|
||||||
|
Kind 31922 is used for date-only events; kind 31923 (time-based) is used
|
||||||
|
when event_start_date / event_end_date include a time component.
|
||||||
|
|
||||||
Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
|
Reference: https://github.com/nostr-protocol/nips/blob/master/52.md
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import coincurve
|
import coincurve
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
@ -17,26 +20,60 @@ from .models import Event
|
||||||
from .nostr.event import NostrEvent
|
from .nostr.event import NostrEvent
|
||||||
|
|
||||||
|
|
||||||
|
def _has_time(value: str | None) -> bool:
|
||||||
|
"""ISO 8601 datetime strings contain a 'T' between date and time."""
|
||||||
|
return value is not None and "T" in value
|
||||||
|
|
||||||
|
|
||||||
|
def _to_unix(value: str) -> int:
|
||||||
|
"""Parse ISO 8601 datetime (assume UTC if naive) to unix seconds."""
|
||||||
|
dt = datetime.fromisoformat(value)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return int(dt.timestamp())
|
||||||
|
|
||||||
|
|
||||||
def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
||||||
"""
|
"""
|
||||||
Convert an Event model to a NIP-52 kind 31922 (date-based) calendar event.
|
Convert an Event model to a NIP-52 calendar event.
|
||||||
|
|
||||||
Tags:
|
Time-based (kind 31923) if event_start_date carries an HH:MM, otherwise
|
||||||
d - event.id (addressable identifier)
|
date-based (kind 31922). Tags:
|
||||||
|
d - event.id
|
||||||
title - event.name
|
title - event.name
|
||||||
start - event.event_start_date (ISO date string)
|
start - unix timestamp (31923) or YYYY-MM-DD (31922)
|
||||||
end - event.event_end_date (optional)
|
end - same encoding (optional)
|
||||||
image - event.banner (optional)
|
image, location, t (categories) - optional
|
||||||
Content: event.info (description)
|
Content: event.info
|
||||||
"""
|
"""
|
||||||
|
time_based = _has_time(event.event_start_date)
|
||||||
|
kind = 31923 if time_based else 31922
|
||||||
|
start_value = (
|
||||||
|
str(_to_unix(event.event_start_date)) if time_based else event.event_start_date
|
||||||
|
)
|
||||||
|
|
||||||
tags = [
|
tags = [
|
||||||
["d", event.id],
|
["d", event.id],
|
||||||
["title", event.name],
|
["title", event.name],
|
||||||
["start", event.event_start_date],
|
["start", start_value],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
end_unix: int | None = None
|
||||||
if event.event_end_date:
|
if event.event_end_date:
|
||||||
tags.append(["end", event.event_end_date])
|
end_value = (
|
||||||
|
str(_to_unix(event.event_end_date)) if time_based else event.event_end_date
|
||||||
|
)
|
||||||
|
tags.append(["end", end_value])
|
||||||
|
if time_based:
|
||||||
|
end_unix = _to_unix(event.event_end_date)
|
||||||
|
|
||||||
|
if time_based:
|
||||||
|
start_unix = _to_unix(event.event_start_date)
|
||||||
|
start_day = start_unix // 86400
|
||||||
|
end_day = (end_unix // 86400) if end_unix is not None else start_day
|
||||||
|
for day in range(start_day, end_day + 1):
|
||||||
|
tags.append(["D", str(day)])
|
||||||
|
|
||||||
if event.banner:
|
if event.banner:
|
||||||
tags.append(["image", event.banner])
|
tags.append(["image", event.banner])
|
||||||
if event.location:
|
if event.location:
|
||||||
|
|
@ -47,7 +84,7 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent:
|
||||||
nostr_event = NostrEvent(
|
nostr_event = NostrEvent(
|
||||||
pubkey=pubkey,
|
pubkey=pubkey,
|
||||||
created_at=int(time.time()),
|
created_at=int(time.time()),
|
||||||
kind=31922,
|
kind=kind,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
content=event.info or "",
|
content=event.info or "",
|
||||||
)
|
)
|
||||||
|
|
@ -59,15 +96,17 @@ def build_nip52_delete_event(event: Event, pubkey: str) -> NostrEvent:
|
||||||
"""
|
"""
|
||||||
Build a kind 5 delete event for a published NIP-52 calendar event.
|
Build a kind 5 delete event for a published NIP-52 calendar event.
|
||||||
|
|
||||||
Uses an 'a' tag to reference the parameterized replaceable event
|
Uses an 'a' tag to reference the parameterized replaceable event per
|
||||||
(kind 31922) per NIP-09.
|
NIP-09. The referenced kind must match what we published — 31923 for
|
||||||
|
time-based events, 31922 for date-only.
|
||||||
"""
|
"""
|
||||||
|
referenced_kind = 31923 if _has_time(event.event_start_date) else 31922
|
||||||
nostr_event = NostrEvent(
|
nostr_event = NostrEvent(
|
||||||
pubkey=pubkey,
|
pubkey=pubkey,
|
||||||
created_at=int(time.time()),
|
created_at=int(time.time()),
|
||||||
kind=5,
|
kind=5,
|
||||||
tags=[
|
tags=[
|
||||||
["a", f"31922:{pubkey}:{event.id}"],
|
["a", f"{referenced_kind}:{pubkey}:{event.id}"],
|
||||||
],
|
],
|
||||||
content="Event canceled",
|
content="Event canceled",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -275,11 +275,39 @@ window.PageEvents = {
|
||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
foldDateTime(day, time) {
|
||||||
|
// Combine separate date/time inputs into the wire format
|
||||||
|
// expected by the events extension: "YYYY-MM-DD" or
|
||||||
|
// "YYYY-MM-DDTHH:MM" (time is optional).
|
||||||
|
if (!day) return null
|
||||||
|
return time ? `${day}T${time}` : day
|
||||||
|
},
|
||||||
|
splitDateTime(value) {
|
||||||
|
// Inverse of foldDateTime: split a stored string back into the
|
||||||
|
// day/time pieces the form inputs bind to.
|
||||||
|
if (!value) return {day: '', time: ''}
|
||||||
|
const [day, time = ''] = value.split('T')
|
||||||
|
// Time inputs only accept HH:MM, drop any seconds we stored.
|
||||||
|
return {day, time: time.slice(0, 5)}
|
||||||
|
},
|
||||||
sendEventData() {
|
sendEventData() {
|
||||||
const wallet = _.findWhere(this.g.user.wallets, {
|
const wallet = _.findWhere(this.g.user.wallets, {
|
||||||
id: this.formDialog.data.wallet
|
id: this.formDialog.data.wallet
|
||||||
})
|
})
|
||||||
const data = this.formDialog.data
|
const data = {...this.formDialog.data}
|
||||||
|
data.event_start_date = this.foldDateTime(
|
||||||
|
data.event_start_day,
|
||||||
|
data.event_start_time
|
||||||
|
)
|
||||||
|
data.event_end_date = this.foldDateTime(
|
||||||
|
data.event_end_day,
|
||||||
|
data.event_end_time
|
||||||
|
)
|
||||||
|
delete data.event_start_day
|
||||||
|
delete data.event_start_time
|
||||||
|
delete data.event_end_day
|
||||||
|
delete data.event_end_time
|
||||||
|
|
||||||
if (data.extra?.promo_codes) {
|
if (data.extra?.promo_codes) {
|
||||||
data.extra.promo_codes = data.extra.promo_codes
|
data.extra.promo_codes = data.extra.promo_codes
|
||||||
.filter(code => code.code?.trim() !== '')
|
.filter(code => code.code?.trim() !== '')
|
||||||
|
|
@ -303,12 +331,24 @@ window.PageEvents = {
|
||||||
|
|
||||||
openEventDialog(data = false) {
|
openEventDialog(data = false) {
|
||||||
if (data && data.id) {
|
if (data && data.id) {
|
||||||
this.formDialog.data = {...data}
|
const start = this.splitDateTime(data.event_start_date)
|
||||||
|
const end = this.splitDateTime(data.event_end_date)
|
||||||
|
this.formDialog.data = {
|
||||||
|
...data,
|
||||||
|
event_start_day: start.day,
|
||||||
|
event_start_time: start.time,
|
||||||
|
event_end_day: end.day,
|
||||||
|
event_end_time: end.time
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.formDialog.data = {
|
this.formDialog.data = {
|
||||||
currency: 'sats',
|
currency: 'sats',
|
||||||
allow_fiat: false,
|
allow_fiat: false,
|
||||||
fiat_currency: 'GBP',
|
fiat_currency: 'GBP',
|
||||||
|
event_start_day: '',
|
||||||
|
event_start_time: '',
|
||||||
|
event_end_day: '',
|
||||||
|
event_end_time: '',
|
||||||
extra: {
|
extra: {
|
||||||
conditional: false,
|
conditional: false,
|
||||||
min_tickets: 1,
|
min_tickets: 1,
|
||||||
|
|
|
||||||
|
|
@ -475,28 +475,46 @@
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row q-col-gutter-sm">
|
||||||
<div class="col-4">Event begins</div>
|
<div class="col-4">Event begins</div>
|
||||||
<div class="col-8">
|
<div class="col-5">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.event_start_date"
|
v-model.trim="formDialog.data.event_start_day"
|
||||||
type="date"
|
type="date"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.event_start_time"
|
||||||
|
type="time"
|
||||||
|
hint="Optional"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row q-col-gutter-sm">
|
||||||
<div class="col-4">Event ends</div>
|
<div class="col-4">Event ends</div>
|
||||||
<div class="col-8">
|
<div class="col-5">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.event_end_date"
|
v-model.trim="formDialog.data.event_end_day"
|
||||||
type="date"
|
type="date"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.event_end_time"
|
||||||
|
type="time"
|
||||||
|
hint="Optional"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-col-gutter-sm">
|
<div class="row q-col-gutter-sm">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|
@ -710,8 +728,8 @@
|
||||||
formDialog.data.name == null ||
|
formDialog.data.name == null ||
|
||||||
formDialog.data.info == null ||
|
formDialog.data.info == null ||
|
||||||
formDialog.data.closing_date == null ||
|
formDialog.data.closing_date == null ||
|
||||||
formDialog.data.event_start_date == null ||
|
formDialog.data.event_start_day == null ||
|
||||||
formDialog.data.event_end_date == null ||
|
formDialog.data.event_end_day == null ||
|
||||||
formDialog.data.amount_tickets == null ||
|
formDialog.data.amount_tickets == null ||
|
||||||
formDialog.data.price_per_ticket == null
|
formDialog.data.price_per_ticket == null
|
||||||
"
|
"
|
||||||
|
|
|
||||||
13
views_api.py
13
views_api.py
|
|
@ -151,9 +151,16 @@ async def api_get_event(event_id: str) -> Event:
|
||||||
closing_date = (
|
closing_date = (
|
||||||
event.closing_date or event.event_end_date or event.event_start_date
|
event.closing_date or event.event_end_date or event.event_start_date
|
||||||
)
|
)
|
||||||
is_window_open = datetime.now(timezone.utc) < datetime.strptime(
|
# Accept either YYYY-MM-DD or full ISO 8601 datetime (event_end_date
|
||||||
closing_date, "%Y-%m-%d"
|
# may carry a time component since v1.3.0-aio.3 / our start-end-time
|
||||||
).replace(tzinfo=timezone.utc)
|
# feature).
|
||||||
|
try:
|
||||||
|
closing_dt = datetime.fromisoformat(closing_date)
|
||||||
|
except ValueError:
|
||||||
|
closing_dt = datetime.strptime(closing_date[:10], "%Y-%m-%d")
|
||||||
|
if closing_dt.tzinfo is None:
|
||||||
|
closing_dt = closing_dt.replace(tzinfo=timezone.utc)
|
||||||
|
is_window_open = datetime.now(timezone.utc) < closing_dt
|
||||||
is_min_tickets_met = (
|
is_min_tickets_met = (
|
||||||
event.sold >= event.extra.min_tickets if event.extra.conditional else True
|
event.sold >= event.extra.min_tickets if event.extra.conditional else True
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue