add API routes, admin UI, and SPA bootstrap
views_api wires the full task lifecycle: create / update / delete / list (per-wallet, public, admin-all) and the completion flow (claim / start / complete via POST, unclaim via DELETE, plus a "mine" lookup for the current user's claim on a task or specific occurrence). Auth model: tasks are owned by an LNbits wallet but signed with the wallet owner's account.pubkey — _wallet_pubkey resolves that pubkey at create time and refuses to create tasks for accounts that haven't generated a keypair yet, so we never publish a task we can't sign. Completions optimistically insert with a local hash, publish to Nostr, then re-insert under the actual event id so a later delete can find it. Static SPA: Quasar-UMD index.vue / index.js mirroring the events extension layout — wallet picker, task table, create/edit dialog with optional daily/weekly recurrence, plus an admin-only public_listing toggle. /tasks/:id and display.vue intentionally left out until the public task page lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
24acbe6674
commit
48a63c0338
5 changed files with 631 additions and 12 deletions
242
views_api.py
242
views_api.py
|
|
@ -1,5 +1,243 @@
|
|||
from fastapi import APIRouter
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.crud.users import get_account
|
||||
from lnbits.core.models import Account, WalletTypeInfo
|
||||
from lnbits.decorators import check_admin, require_admin_key, require_invoice_key
|
||||
|
||||
from .crud import (
|
||||
create_completion,
|
||||
create_task,
|
||||
delete_completion,
|
||||
delete_task,
|
||||
delete_task_completions,
|
||||
get_all_tasks,
|
||||
get_completion,
|
||||
get_completions_for_task,
|
||||
get_public_tasks,
|
||||
get_settings,
|
||||
get_task,
|
||||
get_tasks,
|
||||
update_settings,
|
||||
update_task,
|
||||
)
|
||||
from .models import (
|
||||
CreateTask,
|
||||
CreateTaskCompletion,
|
||||
PublicTask,
|
||||
Task,
|
||||
TaskCompletion,
|
||||
TasksSettings,
|
||||
)
|
||||
from .nostr_hooks import (
|
||||
publish_completion_delete,
|
||||
publish_or_delete_task_event,
|
||||
publish_task_completion,
|
||||
)
|
||||
|
||||
tasks_api_router = APIRouter(prefix="/api/v1/tasks")
|
||||
|
||||
# API routes are filled in by the next commit.
|
||||
|
||||
async def _wallet_pubkey(wallet_id: str) -> str:
|
||||
"""Resolve the Nostr pubkey for the wallet's owning account. Tasks are
|
||||
published as that pubkey, so we refuse to create tasks for accounts
|
||||
that haven't generated a keypair yet."""
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
|
||||
wallet_obj = await get_wallet(wallet_id)
|
||||
if not wallet_obj:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.")
|
||||
account = await get_account(wallet_obj.user)
|
||||
if not account or not getattr(account, "pubkey", None):
|
||||
raise HTTPException(
|
||||
HTTPStatus.PRECONDITION_FAILED,
|
||||
"Account has no Nostr pubkey; generate one before creating tasks.",
|
||||
)
|
||||
return account.pubkey # type: ignore[attr-defined]
|
||||
|
||||
|
||||
# Literal-prefix routes (/public, /all, /settings, /completions) MUST come
|
||||
# before any "/{task_id}" route so FastAPI doesn't match them as path params.
|
||||
|
||||
|
||||
@tasks_api_router.get("")
|
||||
async def api_tasks(
|
||||
all_wallets: bool = Query(False),
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
) -> list[Task]:
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
if all_wallets:
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
return await get_tasks(wallet_ids)
|
||||
|
||||
|
||||
@tasks_api_router.get("/public")
|
||||
async def api_tasks_public() -> list[PublicTask]:
|
||||
"""Public listing. Gated by the public_listing settings flag — when it
|
||||
is off we return an empty list rather than 404 so SPA callers can render
|
||||
a clean empty state."""
|
||||
settings = await get_settings()
|
||||
if not settings.public_listing:
|
||||
return []
|
||||
tasks = await get_public_tasks()
|
||||
return [PublicTask(**t.dict()) for t in tasks]
|
||||
|
||||
|
||||
@tasks_api_router.get("/all")
|
||||
async def api_tasks_all(admin: Account = Depends(check_admin)) -> list[Task]:
|
||||
return await get_all_tasks()
|
||||
|
||||
|
||||
@tasks_api_router.get("/settings")
|
||||
async def api_get_settings(
|
||||
admin: Account = Depends(check_admin),
|
||||
) -> TasksSettings:
|
||||
return await get_settings()
|
||||
|
||||
|
||||
@tasks_api_router.put("/settings")
|
||||
async def api_update_settings(
|
||||
data: TasksSettings,
|
||||
admin: Account = Depends(check_admin),
|
||||
) -> TasksSettings:
|
||||
return await update_settings(data)
|
||||
|
||||
|
||||
@tasks_api_router.post("")
|
||||
async def api_task_create(
|
||||
data: CreateTask,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Task:
|
||||
if not data.wallet:
|
||||
data.wallet = wallet.wallet.id
|
||||
pubkey = await _wallet_pubkey(data.wallet)
|
||||
task = await create_task(pubkey, data)
|
||||
await publish_or_delete_task_event(task)
|
||||
return task
|
||||
|
||||
|
||||
@tasks_api_router.get("/{task_id}", response_model=Task)
|
||||
async def api_get_task(task_id: str) -> Task:
|
||||
task = await get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Task not found.")
|
||||
return task
|
||||
|
||||
|
||||
@tasks_api_router.put("/{task_id}")
|
||||
async def api_task_update(
|
||||
task_id: str,
|
||||
data: CreateTask,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Task:
|
||||
task = await get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Task not found.")
|
||||
if task.wallet != wallet.wallet.id:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your task.")
|
||||
|
||||
# Only mutate fields the caller is allowed to change; pubkey and d_tag
|
||||
# are part of the replaceable Nostr address and must stay stable.
|
||||
for k, v in data.dict(exclude={"wallet"}).items():
|
||||
setattr(task, k, v)
|
||||
task = await update_task(task)
|
||||
|
||||
if task.nostr_event_id:
|
||||
await publish_or_delete_task_event(task)
|
||||
return task
|
||||
|
||||
|
||||
@tasks_api_router.delete("/{task_id}")
|
||||
async def api_task_delete(
|
||||
task_id: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> None:
|
||||
task = await get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Task not found.")
|
||||
if task.wallet != wallet.wallet.id:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your task.")
|
||||
|
||||
if task.nostr_event_id:
|
||||
await publish_or_delete_task_event(task, delete=True)
|
||||
await delete_task(task_id)
|
||||
await delete_task_completions(task.address)
|
||||
|
||||
|
||||
@tasks_api_router.get("/{task_id}/completions")
|
||||
async def api_task_completions(task_id: str) -> list[TaskCompletion]:
|
||||
task = await get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Task not found.")
|
||||
return await get_completions_for_task(task.address)
|
||||
|
||||
|
||||
@tasks_api_router.post("/{task_id}/completions")
|
||||
async def api_task_complete(
|
||||
task_id: str,
|
||||
data: CreateTaskCompletion,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> TaskCompletion:
|
||||
"""Claim / start / complete / block / cancel a task (or specific recurring
|
||||
occurrence). The completer uses their own account pubkey, NOT the task
|
||||
author's, since anyone in the audience can act on a task."""
|
||||
task = await get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Task not found.")
|
||||
|
||||
completer_pubkey = await _wallet_pubkey(wallet.wallet.id)
|
||||
|
||||
# The route parameter is the local task id, but the Nostr address is
|
||||
# always (pubkey, d_tag) — overwrite whatever the client sent in the
|
||||
# body so we can't be tricked into pointing at a different task.
|
||||
data.task_address = task.address
|
||||
|
||||
completion = await create_completion(completer_pubkey, data)
|
||||
|
||||
nostr_id = await publish_task_completion(task, completion)
|
||||
if nostr_id and nostr_id != completion.id:
|
||||
# Replace the locally-generated hash with the actual Nostr event id
|
||||
# so a later delete can find it.
|
||||
await delete_completion(completion.id)
|
||||
completion = await create_completion(
|
||||
completer_pubkey, data, nostr_event_id=nostr_id
|
||||
)
|
||||
return completion
|
||||
|
||||
|
||||
@tasks_api_router.delete("/completions/{completion_id}")
|
||||
async def api_completion_delete(
|
||||
completion_id: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> None:
|
||||
"""Unclaim/withdraw a completion. Only the completer may delete it."""
|
||||
from .crud import get_completion_by_id
|
||||
|
||||
completion = await get_completion_by_id(completion_id)
|
||||
if not completion:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Completion not found.")
|
||||
|
||||
completer_pubkey = await _wallet_pubkey(wallet.wallet.id)
|
||||
if completion.pubkey != completer_pubkey:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your completion.")
|
||||
|
||||
await publish_completion_delete(wallet.wallet.id, completion_id)
|
||||
await delete_completion(completion_id)
|
||||
|
||||
|
||||
# Convenience: look up a completion for the current user on a given task,
|
||||
# optionally for a specific occurrence date. Returns 204 when absent so a
|
||||
# client can poll without alarmist 404s in the console.
|
||||
@tasks_api_router.get("/{task_id}/completions/mine")
|
||||
async def api_my_completion(
|
||||
task_id: str,
|
||||
occurrence: str | None = Query(None),
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
) -> TaskCompletion | None:
|
||||
task = await get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Task not found.")
|
||||
completer_pubkey = await _wallet_pubkey(wallet.wallet.id)
|
||||
return await get_completion(task.address, completer_pubkey, occurrence)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue