Merge branch 'main' into fix/paymentChain_splitPayment
This commit is contained in:
commit
954bbc6de4
|
@ -88,7 +88,7 @@ class Payment(BaseModel):
|
|||
preimage: str
|
||||
payment_hash: str
|
||||
expiry: Optional[float]
|
||||
extra: Optional[Dict] = {}
|
||||
extra: Dict = {}
|
||||
wallet_id: str
|
||||
webhook: Optional[str]
|
||||
webhook_status: Optional[int]
|
||||
|
|
|
@ -468,10 +468,10 @@
|
|||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
:label="'Amount (' + receive.unit + ') *'"
|
||||
:mask="receive.unit != 'sats' ? '#.##' : '#'"
|
||||
:mask="receive.unit != 'sat' ? '#.##' : '#'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="receive.unit != 'sats' ? '0.01' : '1'"
|
||||
:step="receive.unit != 'sat' ? '0.01' : '1'"
|
||||
:min="receive.minMax[0]"
|
||||
:max="receive.minMax[1]"
|
||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import secrets
|
||||
from datetime import date, datetime
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
@ -124,7 +124,6 @@ async def get_card_by_otp(otp: str) -> Optional[Card]:
|
|||
|
||||
async def delete_card(card_id: str) -> None:
|
||||
# Delete cards
|
||||
card = await get_card(card_id)
|
||||
await db.execute("DELETE FROM boltcards.cards WHERE id = ?", (card_id,))
|
||||
# Delete hits
|
||||
hits = await get_hits([card_id])
|
||||
|
@ -146,7 +145,7 @@ async def update_card_counter(counter: int, id: str):
|
|||
|
||||
|
||||
async def enable_disable_card(enable: bool, id: str) -> Optional[Card]:
|
||||
row = await db.execute(
|
||||
await db.execute(
|
||||
"UPDATE boltcards.cards SET enable = ? WHERE id = ?",
|
||||
(enable, id),
|
||||
)
|
||||
|
@ -161,7 +160,7 @@ async def update_card_otp(otp: str, id: str):
|
|||
|
||||
|
||||
async def get_hit(hit_id: str) -> Optional[Hit]:
|
||||
row = await db.fetchone(f"SELECT * FROM boltcards.hits WHERE id = ?", (hit_id))
|
||||
row = await db.fetchone(f"SELECT * FROM boltcards.hits WHERE id = ?", (hit_id,))
|
||||
if not row:
|
||||
return None
|
||||
|
||||
|
@ -182,7 +181,7 @@ async def get_hits(cards_ids: Union[str, List[str]]) -> List[Hit]:
|
|||
return [Hit(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_hits_today(card_id: str) -> Optional[Hit]:
|
||||
async def get_hits_today(card_id: str) -> List[Hit]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.hits WHERE card_id = ?",
|
||||
(card_id,),
|
||||
|
@ -259,7 +258,7 @@ async def create_refund(hit_id, refund_amount) -> Refund:
|
|||
|
||||
async def get_refund(refund_id: str) -> Optional[Refund]:
|
||||
row = await db.fetchone(
|
||||
f"SELECT * FROM boltcards.refunds WHERE id = ?", (refund_id)
|
||||
f"SELECT * FROM boltcards.refunds WHERE id = ?", (refund_id,)
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
|
@ -267,7 +266,7 @@ async def get_refund(refund_id: str) -> Optional[Refund]:
|
|||
return Refund.parse_obj(refund)
|
||||
|
||||
|
||||
async def get_refunds(hits_ids: Union[str, List[str]]) -> List[Refund]:
|
||||
async def get_refunds(hits_ids: List[Hit]) -> List[Refund]:
|
||||
if len(hits_ids) == 0:
|
||||
return []
|
||||
|
||||
|
|
|
@ -3,13 +3,9 @@ import secrets
|
|||
from http import HTTPStatus
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends, Query
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from fastapi import HTTPException, Query, Request
|
||||
from lnurl import encode as lnurl_encode
|
||||
from lnurl.types import LnurlPayMetadata
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits import bolt11
|
||||
|
@ -28,14 +24,13 @@ from .crud import (
|
|||
update_card_counter,
|
||||
update_card_otp,
|
||||
)
|
||||
from .models import CreateCardData
|
||||
from .nxp424 import decryptSUN, getSunMAC
|
||||
|
||||
###############LNURLWITHDRAW#################
|
||||
|
||||
# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000
|
||||
@boltcards_ext.get("/api/v1/scan/{external_id}")
|
||||
async def api_scan(p, c, request: Request, external_id: str = None):
|
||||
async def api_scan(p, c, request: Request, external_id: str = Query(None)):
|
||||
# some wallets send everything as lower case, no bueno
|
||||
p = p.upper()
|
||||
c = c.upper()
|
||||
|
@ -63,6 +58,7 @@ async def api_scan(p, c, request: Request, external_id: str = None):
|
|||
await update_card_counter(ctr_int, card.id)
|
||||
|
||||
# gathering some info for hit record
|
||||
assert request.client
|
||||
ip = request.client.host
|
||||
if "x-real-ip" in request.headers:
|
||||
ip = request.headers["x-real-ip"]
|
||||
|
@ -95,7 +91,6 @@ async def api_scan(p, c, request: Request, external_id: str = None):
|
|||
name="boltcards.lnurl_callback",
|
||||
)
|
||||
async def lnurl_callback(
|
||||
request: Request,
|
||||
pr: str = Query(None),
|
||||
k1: str = Query(None),
|
||||
):
|
||||
|
@ -120,7 +115,9 @@ async def lnurl_callback(
|
|||
return {"status": "ERROR", "reason": "Failed to decode payment request"}
|
||||
|
||||
card = await get_card(hit.card_id)
|
||||
assert card
|
||||
hit = await spend_hit(id=hit.id, amount=int(invoice.amount_msat / 1000))
|
||||
assert hit
|
||||
try:
|
||||
await pay_invoice(
|
||||
wallet_id=card.wallet,
|
||||
|
@ -155,7 +152,7 @@ async def api_auth(a, request: Request):
|
|||
|
||||
response = {
|
||||
"card_name": card.card_name,
|
||||
"id": 1,
|
||||
"id": str(1),
|
||||
"k0": card.k0,
|
||||
"k1": card.k1,
|
||||
"k2": card.k2,
|
||||
|
@ -163,7 +160,7 @@ async def api_auth(a, request: Request):
|
|||
"k4": card.k2,
|
||||
"lnurlw_base": "lnurlw://" + lnurlw_base,
|
||||
"protocol_name": "new_bolt_card_response",
|
||||
"protocol_version": 1,
|
||||
"protocol_version": str(1),
|
||||
}
|
||||
|
||||
return response
|
||||
|
@ -179,7 +176,9 @@ async def api_auth(a, request: Request):
|
|||
)
|
||||
async def lnurlp_response(req: Request, hit_id: str = Query(None)):
|
||||
hit = await get_hit(hit_id)
|
||||
assert hit
|
||||
card = await get_card(hit.card_id)
|
||||
assert card
|
||||
if not hit:
|
||||
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
|
||||
if not card.enable:
|
||||
|
@ -199,17 +198,17 @@ async def lnurlp_response(req: Request, hit_id: str = Query(None)):
|
|||
response_class=HTMLResponse,
|
||||
name="boltcards.lnurlp_callback",
|
||||
)
|
||||
async def lnurlp_callback(
|
||||
req: Request, hit_id: str = Query(None), amount: str = Query(None)
|
||||
):
|
||||
async def lnurlp_callback(hit_id: str = Query(None), amount: str = Query(None)):
|
||||
hit = await get_hit(hit_id)
|
||||
assert hit
|
||||
card = await get_card(hit.card_id)
|
||||
assert card
|
||||
if not hit:
|
||||
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=card.wallet,
|
||||
amount=int(amount) / 1000,
|
||||
amount=int(int(amount) / 1000),
|
||||
memo=f"Refund {hit_id}",
|
||||
unhashed_description=LnurlPayMetadata(
|
||||
json.dumps([["text/plain", "Refund"]])
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
import json
|
||||
from sqlite3 import Row
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.params import Query
|
||||
from fastapi import Query, Request
|
||||
from lnurl import Lnurl
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from lnurl import encode as lnurl_encode
|
||||
from lnurl.types import LnurlPayMetadata
|
||||
from pydantic import BaseModel
|
||||
from pydantic.main import BaseModel
|
||||
|
||||
ZERO_KEY = "00000000000000000000000000000000"
|
||||
|
||||
|
@ -32,6 +29,7 @@ class Card(BaseModel):
|
|||
otp: str
|
||||
time: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Card":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
@ -40,7 +38,7 @@ class Card(BaseModel):
|
|||
return lnurl_encode(url)
|
||||
|
||||
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||
return LnurlPayMetadata(json.dumps([["text/plain", self.title]]))
|
||||
return LnurlPayMetadata(json.dumps([["text/plain", self.card_name]]))
|
||||
|
||||
|
||||
class CreateCardData(BaseModel):
|
||||
|
@ -69,6 +67,7 @@ class Hit(BaseModel):
|
|||
amount: int
|
||||
time: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Hit":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
@ -79,5 +78,6 @@ class Refund(BaseModel):
|
|||
refund_amount: int
|
||||
time: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Refund":
|
||||
return cls(**dict(row))
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
|
@ -21,22 +19,25 @@ async def wait_for_paid_invoices():
|
|||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
|
||||
if not payment.extra.get("refund"):
|
||||
return
|
||||
|
||||
if payment.extra.get("wh_status"):
|
||||
# this webhook has already been sent
|
||||
return
|
||||
hit = await get_hit(payment.extra.get("refund"))
|
||||
|
||||
hit = await get_hit(str(payment.extra.get("refund")))
|
||||
|
||||
if hit:
|
||||
refund = await create_refund(
|
||||
hit_id=hit.id, refund_amount=(payment.amount / 1000)
|
||||
)
|
||||
await create_refund(hit_id=hit.id, refund_amount=(payment.amount / 1000))
|
||||
await mark_webhook_sent(payment, 1)
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
if not payment.extra:
|
||||
return
|
||||
|
||||
payment.extra["wh_status"] = status
|
||||
|
||||
await core_db.execute(
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from fastapi import FastAPI, Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
import secrets
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi.params import Depends, Query
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
|
@ -15,13 +11,11 @@ from .crud import (
|
|||
delete_card,
|
||||
enable_disable_card,
|
||||
get_card,
|
||||
get_card_by_otp,
|
||||
get_card_by_uid,
|
||||
get_cards,
|
||||
get_hits,
|
||||
get_refunds,
|
||||
update_card,
|
||||
update_card_otp,
|
||||
)
|
||||
from .models import CreateCardData
|
||||
|
||||
|
@ -33,7 +27,8 @@ async def api_cards(
|
|||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
user = await get_user(g.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
return [card.dict() for card in await get_cards(wallet_ids)]
|
||||
|
||||
|
@ -41,9 +36,8 @@ async def api_cards(
|
|||
@boltcards_ext.post("/api/v1/cards", status_code=HTTPStatus.CREATED)
|
||||
@boltcards_ext.put("/api/v1/cards/{card_id}", status_code=HTTPStatus.OK)
|
||||
async def api_card_create_or_update(
|
||||
# req: Request,
|
||||
data: CreateCardData,
|
||||
card_id: str = None,
|
||||
card_id: str = Query(None),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
try:
|
||||
|
@ -95,6 +89,7 @@ async def api_card_create_or_update(
|
|||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
card = await create_card(wallet_id=wallet.wallet.id, data=data)
|
||||
assert card
|
||||
return card.dict()
|
||||
|
||||
|
||||
|
@ -110,6 +105,7 @@ async def enable_card(
|
|||
if card.wallet != wallet.wallet.id:
|
||||
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
|
||||
card = await enable_disable_card(enable=enable, id=card_id)
|
||||
assert card
|
||||
return card.dict()
|
||||
|
||||
|
||||
|
@ -136,7 +132,8 @@ async def api_hits(
|
|||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
user = await get_user(g.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
cards = await get_cards(wallet_ids)
|
||||
cards_ids = []
|
||||
|
@ -153,15 +150,13 @@ async def api_refunds(
|
|||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
user = await get_user(g.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
cards = await get_cards(wallet_ids)
|
||||
cards_ids = []
|
||||
for card in cards:
|
||||
cards_ids.append(card.id)
|
||||
hits = await get_hits(cards_ids)
|
||||
hits_ids = []
|
||||
for hit in hits:
|
||||
hits_ids.append(hit.id)
|
||||
|
||||
return [refund.dict() for refund in await get_refunds(hits_ids)]
|
||||
return [refund.dict() for refund in await get_refunds(hits)]
|
||||
|
|
|
@ -28,6 +28,7 @@ async def wait_for_paid_invoices():
|
|||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if payment.extra and not payment.extra.get("tag") == "cashu":
|
||||
if payment.extra.get("tag") != "cashu":
|
||||
return
|
||||
|
||||
return
|
||||
|
|
|
@ -24,12 +24,12 @@ async def wait_for_paid_invoices():
|
|||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
webhook = None
|
||||
data = None
|
||||
if not payment.extra or payment.extra.get("tag") != "copilot":
|
||||
if payment.extra.get("tag") != "copilot":
|
||||
# not an copilot invoice
|
||||
return
|
||||
|
||||
webhook = None
|
||||
data = None
|
||||
copilot = await get_copilot(payment.extra.get("copilotid", -1))
|
||||
|
||||
if not copilot:
|
||||
|
|
|
@ -5,11 +5,9 @@ from fastapi.staticfiles import StaticFiles
|
|||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_gerty")
|
||||
|
||||
|
||||
gerty_static_files = [
|
||||
{
|
||||
"path": "/gerty/static",
|
||||
|
|
|
@ -50,11 +50,12 @@ async def create_gerty(wallet_id: str, data: Gerty) -> Gerty:
|
|||
return gerty
|
||||
|
||||
|
||||
async def update_gerty(gerty_id: str, **kwargs) -> Gerty:
|
||||
async def update_gerty(gerty_id: str, **kwargs) -> Optional[Gerty]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE gerty.gertys SET {q} WHERE id = ?", (*kwargs.values(), gerty_id)
|
||||
)
|
||||
|
||||
return await get_gerty(gerty_id)
|
||||
|
||||
|
||||
|
@ -82,7 +83,7 @@ async def delete_gerty(gerty_id: str) -> None:
|
|||
#############MEMPOOL###########
|
||||
|
||||
|
||||
async def get_mempool_info(endPoint: str, gerty) -> Optional[Mempool]:
|
||||
async def get_mempool_info(endPoint: str, gerty) -> dict:
|
||||
logger.debug(endPoint)
|
||||
endpoints = MempoolEndpoint()
|
||||
url = ""
|
||||
|
|
|
@ -3,15 +3,16 @@ import os
|
|||
import random
|
||||
import textwrap
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||
from lnbits.core.crud import get_wallet_for_key
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
|
||||
|
||||
from .crud import get_gerty, get_mempool_info
|
||||
from .crud import get_mempool_info
|
||||
from .number_prefixer import *
|
||||
|
||||
|
||||
|
@ -24,8 +25,8 @@ def get_percent_difference(current, previous, precision=3):
|
|||
def get_text_item_dict(
|
||||
text: str,
|
||||
font_size: int,
|
||||
x_pos: int = None,
|
||||
y_pos: int = None,
|
||||
x_pos: int = -1,
|
||||
y_pos: int = -1,
|
||||
gerty_type: str = "Gerty",
|
||||
):
|
||||
# Get line size by font size
|
||||
|
@ -63,13 +64,41 @@ def get_text_item_dict(
|
|||
# logger.debug('multilineText')
|
||||
# logger.debug(multilineText)
|
||||
|
||||
text = {"value": multilineText, "size": font_size}
|
||||
if x_pos is None and y_pos is None:
|
||||
text["position"] = "center"
|
||||
data_text = {"value": multilineText, "size": font_size}
|
||||
if x_pos == -1 and y_pos == -1:
|
||||
data_text["position"] = "center"
|
||||
else:
|
||||
text["x"] = x_pos
|
||||
text["y"] = y_pos
|
||||
return text
|
||||
data_text["x"] = x_pos if x_pos > 0 else 0
|
||||
data_text["y"] = y_pos if x_pos > 0 else 0
|
||||
return data_text
|
||||
|
||||
|
||||
def get_date_suffix(dayNumber):
|
||||
if 4 <= dayNumber <= 20 or 24 <= dayNumber <= 30:
|
||||
return "th"
|
||||
else:
|
||||
return ["st", "nd", "rd"][dayNumber % 10 - 1]
|
||||
|
||||
|
||||
def get_time_remaining(seconds, granularity=2):
|
||||
intervals = (
|
||||
# ('weeks', 604800), # 60 * 60 * 24 * 7
|
||||
("days", 86400), # 60 * 60 * 24
|
||||
("hours", 3600), # 60 * 60
|
||||
("minutes", 60),
|
||||
("seconds", 1),
|
||||
)
|
||||
|
||||
result = []
|
||||
|
||||
for name, count in intervals:
|
||||
value = seconds // count
|
||||
if value:
|
||||
seconds -= value * count
|
||||
if value == 1:
|
||||
name = name.rstrip("s")
|
||||
result.append("{} {}".format(round(value), name))
|
||||
return ", ".join(result[:granularity])
|
||||
|
||||
|
||||
# format a number for nice display output
|
||||
|
@ -293,8 +322,7 @@ def get_next_update_time(sleep_time_seconds: int = 0, utc_offset: int = 0):
|
|||
def gerty_should_sleep(utc_offset: int = 0):
|
||||
utc_now = datetime.utcnow()
|
||||
local_time = utc_now + timedelta(hours=utc_offset)
|
||||
hours = local_time.strftime("%H")
|
||||
hours = int(hours)
|
||||
hours = int(local_time.strftime("%H"))
|
||||
if hours >= 22 and hours <= 23:
|
||||
return True
|
||||
else:
|
||||
|
@ -352,23 +380,17 @@ async def get_mining_stat(stat_slug: str, gerty):
|
|||
|
||||
|
||||
async def api_get_mining_stat(stat_slug: str, gerty):
|
||||
stat = ""
|
||||
stat = {}
|
||||
if stat_slug == "mining_current_hash_rate":
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await get_mempool_info("hashrate_1m", gerty)
|
||||
data = r
|
||||
stat = {}
|
||||
stat["current"] = data["currentHashrate"]
|
||||
stat["1w"] = data["hashrates"][len(data["hashrates"]) - 7]["avgHashrate"]
|
||||
r = await get_mempool_info("hashrate_1m", gerty)
|
||||
data = r
|
||||
stat["current"] = data["currentHashrate"]
|
||||
stat["1w"] = data["hashrates"][len(data["hashrates"]) - 7]["avgHashrate"]
|
||||
elif stat_slug == "mining_current_difficulty":
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await get_mempool_info("hashrate_1m", gerty)
|
||||
data = r
|
||||
stat = {}
|
||||
stat["current"] = data["currentDifficulty"]
|
||||
stat["previous"] = data["difficulty"][len(data["difficulty"]) - 2][
|
||||
"difficulty"
|
||||
]
|
||||
r = await get_mempool_info("hashrate_1m", gerty)
|
||||
data = r
|
||||
stat["current"] = data["currentDifficulty"]
|
||||
stat["previous"] = data["difficulty"][len(data["difficulty"]) - 2]["difficulty"]
|
||||
return stat
|
||||
|
||||
|
||||
|
@ -384,7 +406,7 @@ async def get_satoshi():
|
|||
quote = satoshiQuotes[random.randint(0, len(satoshiQuotes) - 1)]
|
||||
# logger.debug(quote.text)
|
||||
if len(quote["text"]) > maxQuoteLength:
|
||||
logger.debug("Quote is too long, getting another")
|
||||
logger.trace("Quote is too long, getting another")
|
||||
return await get_satoshi()
|
||||
else:
|
||||
return quote
|
||||
|
@ -399,15 +421,16 @@ def get_screen_slug_by_index(index: int, screens_list):
|
|||
|
||||
|
||||
# Get a list of text items for the screen number
|
||||
async def get_screen_data(screen_num: int, screens_list: dict, gerty):
|
||||
async def get_screen_data(screen_num: int, screens_list: list, gerty):
|
||||
screen_slug = get_screen_slug_by_index(screen_num, screens_list)
|
||||
# first get the relevant slug from the display_preferences
|
||||
areas = []
|
||||
areas: List = []
|
||||
title = ""
|
||||
|
||||
if screen_slug == "dashboard":
|
||||
title = gerty.name
|
||||
areas = await get_dashboard(gerty)
|
||||
|
||||
if screen_slug == "lnbits_wallets_balance":
|
||||
wallets = await get_lnbits_wallet_balances(gerty)
|
||||
|
||||
|
@ -505,10 +528,10 @@ async def get_screen_data(screen_num: int, screens_list: dict, gerty):
|
|||
title = "Lightning Network"
|
||||
areas = await get_lightning_stats(gerty)
|
||||
|
||||
data = {}
|
||||
data["title"] = title
|
||||
data["areas"] = areas
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"areas": areas,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
|
@ -570,7 +593,7 @@ async def get_dashboard(gerty):
|
|||
text = []
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=await get_time_remaining_next_difficulty_adjustment(gerty),
|
||||
text=await get_time_remaining_next_difficulty_adjustment(gerty) or "0",
|
||||
font_size=15,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
|
@ -602,7 +625,7 @@ async def get_lnbits_wallet_balances(gerty):
|
|||
return wallets
|
||||
|
||||
|
||||
async def get_placeholder_text():
|
||||
async def get_placeholder_text(gerty):
|
||||
return [
|
||||
get_text_item_dict(
|
||||
text="Some placeholder text",
|
||||
|
@ -810,14 +833,14 @@ async def get_time_remaining_next_difficulty_adjustment(gerty):
|
|||
r = await get_mempool_info("difficulty_adjustment", gerty)
|
||||
stat = r["remainingTime"]
|
||||
time = get_time_remaining(stat / 1000, 3)
|
||||
return time
|
||||
return time
|
||||
|
||||
|
||||
async def get_mempool_stat(stat_slug: str, gerty):
|
||||
text = []
|
||||
if isinstance(gerty.mempool_endpoint, str):
|
||||
if stat_slug == "mempool_tx_count":
|
||||
r = get_mempool_info("mempool", gerty)
|
||||
r = await get_mempool_info("mempool", gerty)
|
||||
if stat_slug == "mempool_tx_count":
|
||||
stat = round(r["count"])
|
||||
text.append(
|
||||
|
@ -921,31 +944,3 @@ async def get_mempool_stat(stat_slug: str, gerty):
|
|||
)
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def get_date_suffix(dayNumber):
|
||||
if 4 <= dayNumber <= 20 or 24 <= dayNumber <= 30:
|
||||
return "th"
|
||||
else:
|
||||
return ["st", "nd", "rd"][dayNumber % 10 - 1]
|
||||
|
||||
|
||||
def get_time_remaining(seconds, granularity=2):
|
||||
intervals = (
|
||||
# ('weeks', 604800), # 60 * 60 * 24 * 7
|
||||
("days", 86400), # 60 * 60 * 24
|
||||
("hours", 3600), # 60 * 60
|
||||
("minutes", 60),
|
||||
("seconds", 1),
|
||||
)
|
||||
|
||||
result = []
|
||||
|
||||
for name, count in intervals:
|
||||
value = seconds // count
|
||||
if value:
|
||||
seconds -= value * count
|
||||
if value == 1:
|
||||
name = name.rstrip("s")
|
||||
result.append("{} {}".format(round(value), name))
|
||||
return ", ".join(result[:granularity])
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from sqlite3 import Row
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel
|
||||
|
|
|
@ -32,7 +32,10 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
|
|||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="q-pa-md row items-start q-gutter-md" v-if="lnbits_wallets_balance">
|
||||
<div
|
||||
class="q-pa-md row items-start q-gutter-md"
|
||||
v-if="lnbits_wallets_balance[0]"
|
||||
>
|
||||
<q-card
|
||||
class="q-pa-sm"
|
||||
v-for="(wallet, t) in lnbits_wallets_balance"
|
||||
|
@ -49,7 +52,7 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
|
|||
|
||||
<div
|
||||
class="q-pa-md row items-start q-gutter-md"
|
||||
v-if="dashboard_onchain || dashboard_mining || lightning_dashboard"
|
||||
v-if="dashboard_onchain[0] || dashboard_mining[0] || lightning_dashboard[0] || url_checker[0]"
|
||||
>
|
||||
<q-card
|
||||
class="q-pa-sm"
|
||||
|
@ -67,7 +70,7 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
|
|||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-pa-sm" v-if="dashboard_mining" unelevated class="q-pa-sm">
|
||||
<q-card class="q-pa-sm" v-if="dashboard_mining[0]" unelevated class="q-pa-sm">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Mining</div>
|
||||
</q-card-section>
|
||||
|
@ -78,7 +81,12 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
|
|||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-pa-sm" v-if="lightning_dashboard" unelevated class="q-pa-sm">
|
||||
<q-card
|
||||
class="q-pa-sm"
|
||||
v-if="lightning_dashboard[0]"
|
||||
unelevated
|
||||
class="q-pa-sm"
|
||||
>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Lightning (Last 7 days)</div>
|
||||
</q-card-section>
|
||||
|
@ -88,7 +96,6 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
|
|||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-pa-sm" v-if="url_checker" unelevated class="q-pa-sm">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Servers to check</div>
|
||||
|
@ -153,7 +160,13 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
|
|||
lnbits_wallets_balance: {},
|
||||
dashboard_onchain: {},
|
||||
fun_satoshi_quotes: {},
|
||||
fun_exchange_market_rate: {},
|
||||
fun_exchange_market_rate: {
|
||||
unit: ''
|
||||
},
|
||||
dashboard_mining: {},
|
||||
lightning_dashboard: {},
|
||||
url_checker: {},
|
||||
dashboard_mining: {},
|
||||
gerty: [],
|
||||
gerty_id: `{{gerty}}`,
|
||||
gertyname: '',
|
||||
|
@ -182,7 +195,6 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
|
|||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
}
|
||||
console.log(this.gerty)
|
||||
for (let i = 0; i < this.gerty.length; i++) {
|
||||
if (this.gerty[i].screen.group == 'lnbits_wallets_balance') {
|
||||
for (let q = 0; q < this.gerty[i].screen.areas.length; q++) {
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
|
@ -13,7 +10,6 @@ from lnbits.decorators import check_user_exists
|
|||
|
||||
from . import gerty_ext, gerty_renderer
|
||||
from .crud import get_gerty
|
||||
from .views_api import api_gerty_json
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
|
|
@ -1,24 +1,12 @@
|
|||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from lnurl import decode as decode_lnurl
|
||||
from fastapi import Depends, Query
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment, api_wallet
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
|
||||
|
||||
from . import gerty_ext
|
||||
from .crud import (
|
||||
|
@ -29,8 +17,14 @@ from .crud import (
|
|||
get_mempool_info,
|
||||
update_gerty,
|
||||
)
|
||||
from .helpers import *
|
||||
from .models import Gerty, MempoolEndpoint
|
||||
from .helpers import (
|
||||
gerty_should_sleep,
|
||||
get_next_update_time,
|
||||
get_satoshi,
|
||||
get_screen_data,
|
||||
get_screen_slug_by_index,
|
||||
)
|
||||
from .models import Gerty
|
||||
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty", status_code=HTTPStatus.OK)
|
||||
|
@ -39,7 +33,8 @@ async def api_gertys(
|
|||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
return [gerty.dict() for gerty in await get_gertys(wallet_ids)]
|
||||
|
||||
|
@ -51,7 +46,6 @@ async def api_link_create_or_update(
|
|||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
gerty_id: str = Query(None),
|
||||
):
|
||||
logger.debug(data)
|
||||
if gerty_id:
|
||||
gerty = await get_gerty(gerty_id)
|
||||
if not gerty:
|
||||
|
@ -67,6 +61,9 @@ async def api_link_create_or_update(
|
|||
|
||||
data.wallet = wallet.wallet.id
|
||||
gerty = await update_gerty(gerty_id, **data.dict())
|
||||
assert gerty, HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist"
|
||||
)
|
||||
else:
|
||||
gerty = await create_gerty(wallet_id=wallet.wallet.id, data=data)
|
||||
|
||||
|
@ -93,11 +90,11 @@ async def api_gerty_delete(
|
|||
|
||||
@gerty_ext.get("/api/v1/gerty/satoshiquote", status_code=HTTPStatus.OK)
|
||||
async def api_gerty_satoshi():
|
||||
return await get_satoshi
|
||||
return await get_satoshi()
|
||||
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty/pages/{gerty_id}/{p}")
|
||||
async def api_gerty_json(gerty_id: str, p: int = None): # page number
|
||||
async def api_gerty_json(gerty_id: str, p: int = 0): # page number
|
||||
gerty = await get_gerty(gerty_id)
|
||||
|
||||
if not gerty:
|
||||
|
@ -117,7 +114,7 @@ async def api_gerty_json(gerty_id: str, p: int = None): # page number
|
|||
enabled_screen_count += 1
|
||||
enabled_screens.append(screen_slug)
|
||||
|
||||
logger.debug("Screeens " + str(enabled_screens))
|
||||
logger.debug("Screens " + str(enabled_screens))
|
||||
data = await get_screen_data(p, enabled_screens, gerty)
|
||||
|
||||
next_screen_number = 0 if ((p + 1) >= enabled_screen_count) else p + 1
|
||||
|
|
|
@ -6,7 +6,6 @@ from . import db
|
|||
from .models import (
|
||||
CreateInvoiceData,
|
||||
CreateInvoiceItemData,
|
||||
CreatePaymentData,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
Payment,
|
||||
|
@ -30,7 +29,7 @@ async def get_invoice_items(invoice_id: str) -> List[InvoiceItem]:
|
|||
return [InvoiceItem.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_invoice_item(item_id: str) -> InvoiceItem:
|
||||
async def get_invoice_item(item_id: str) -> Optional[InvoiceItem]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM invoices.invoice_items WHERE id = ?", (item_id,)
|
||||
)
|
||||
|
@ -61,7 +60,7 @@ async def get_invoice_payments(invoice_id: str) -> List[Payment]:
|
|||
return [Payment.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_invoice_payment(payment_id: str) -> Payment:
|
||||
async def get_invoice_payment(payment_id: str) -> Optional[Payment]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM invoices.payments WHERE id = ?", (payment_id,)
|
||||
)
|
||||
|
@ -120,7 +119,9 @@ async def create_invoice_items(
|
|||
return invoice_items
|
||||
|
||||
|
||||
async def update_invoice_internal(wallet_id: str, data: UpdateInvoiceData) -> Invoice:
|
||||
async def update_invoice_internal(
|
||||
wallet_id: str, data: Union[UpdateInvoiceData, Invoice]
|
||||
) -> Invoice:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE invoices.invoices
|
||||
|
@ -155,21 +156,21 @@ async def update_invoice_items(
|
|||
updated_items.append(item.id)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE invoices.invoice_items
|
||||
UPDATE invoices.invoice_items
|
||||
SET description = ?, amount = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(item.description, int(item.amount * 100), item.id),
|
||||
)
|
||||
|
||||
placeholders = ",".join("?" for i in range(len(updated_items)))
|
||||
placeholders = ",".join("?" for _ in range(len(updated_items)))
|
||||
if not placeholders:
|
||||
placeholders = "?"
|
||||
updated_items = ("skip",)
|
||||
updated_items = ["skip"]
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
DELETE FROM invoices.invoice_items
|
||||
DELETE FROM invoices.invoice_items
|
||||
WHERE invoice_id = ?
|
||||
AND id NOT IN ({placeholders})
|
||||
""",
|
||||
|
@ -180,8 +181,11 @@ async def update_invoice_items(
|
|||
)
|
||||
|
||||
for item in data:
|
||||
if not item.id:
|
||||
await create_invoice_items(invoice_id=invoice_id, data=[item])
|
||||
if not item:
|
||||
await create_invoice_items(
|
||||
invoice_id=invoice_id,
|
||||
data=[CreateInvoiceItemData(description=item.description)],
|
||||
)
|
||||
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
return invoice_items
|
||||
|
|
|
@ -2,7 +2,7 @@ from enum import Enum
|
|||
from sqlite3 import Row
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import asyncio
|
||||
import json
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.tasks import internal_invoice_queue, register_invoice_listener
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import (
|
||||
create_invoice_payment,
|
||||
|
@ -14,6 +12,7 @@ from .crud import (
|
|||
get_payments_total,
|
||||
update_invoice_internal,
|
||||
)
|
||||
from .models import InvoiceStatusEnum
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
|
@ -27,16 +26,18 @@ async def wait_for_paid_invoices():
|
|||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if payment.extra.get("tag") != "invoices":
|
||||
# not relevant
|
||||
return
|
||||
|
||||
invoice_id = payment.extra.get("invoice_id")
|
||||
assert invoice_id
|
||||
|
||||
payment = await create_invoice_payment(
|
||||
invoice_id=invoice_id, amount=payment.extra.get("famount")
|
||||
)
|
||||
amount = payment.extra.get("famount")
|
||||
assert amount
|
||||
|
||||
await create_invoice_payment(invoice_id=invoice_id, amount=amount)
|
||||
|
||||
invoice = await get_invoice(invoice_id)
|
||||
assert invoice
|
||||
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
invoice_total = await get_invoice_total(invoice_items)
|
||||
|
@ -45,7 +46,7 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
payments_total = await get_payments_total(invoice_payments)
|
||||
|
||||
if payments_total >= invoice_total:
|
||||
invoice.status = "paid"
|
||||
invoice.status = InvoiceStatusEnum.paid
|
||||
await update_invoice_internal(invoice.wallet, invoice)
|
||||
|
||||
return
|
||||
|
|
|
@ -257,7 +257,7 @@ block page %}
|
|||
>
|
||||
<q-responsive :ratio="1" class="q-mx-xs">
|
||||
<qrcode
|
||||
:value="'lightning:' + qrCodeDialog.data.payment_request.toUpperCase()"
|
||||
:value="'lightning:' + qrCodeDialog.data.payment_request"
|
||||
:options="{width: 400}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
|
||||
from . import invoices_ext
|
||||
|
@ -33,7 +31,8 @@ async def api_invoices(
|
|||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
return [invoice.dict() for invoice in await get_invoices(wallet_ids)]
|
||||
|
||||
|
@ -83,9 +82,7 @@ async def api_invoice_update(
|
|||
@invoices_ext.post(
|
||||
"/api/v1/invoice/{invoice_id}/payments", status_code=HTTPStatus.CREATED
|
||||
)
|
||||
async def api_invoices_create_payment(
|
||||
famount: int = Query(..., ge=1), invoice_id: str = None
|
||||
):
|
||||
async def api_invoices_create_payment(invoice_id: str, famount: int = Query(..., ge=1)):
|
||||
invoice = await get_invoice(invoice_id)
|
||||
invoice_items = await get_invoice_items(invoice_id)
|
||||
invoice_total = await get_invoice_total(invoice_items)
|
||||
|
|
|
@ -17,8 +17,8 @@ async def wait_for_paid_invoices():
|
|||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if payment.extra:
|
||||
if payment.extra.get("tag") != "jukebox":
|
||||
# not a jukebox invoice
|
||||
return
|
||||
await update_jukebox_payment(payment.payment_hash, paid=True)
|
||||
if payment.extra.get("tag") != "jukebox":
|
||||
# not a jukebox invoice
|
||||
return
|
||||
|
||||
await update_jukebox_payment(payment.payment_hash, paid=True)
|
||||
|
|
|
@ -16,7 +16,7 @@ async def cloudflare_create_record(domain: Domains, ip: str):
|
|||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
cf_response = ""
|
||||
cf_response = {}
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.post(
|
||||
|
@ -31,9 +31,9 @@ async def cloudflare_create_record(domain: Domains, ip: str):
|
|||
},
|
||||
timeout=40,
|
||||
)
|
||||
cf_response = json.loads(r.text)
|
||||
cf_response = r.json()
|
||||
except AssertionError:
|
||||
cf_response = "Error occured"
|
||||
cf_response = {"error": "Error occured"}
|
||||
return cf_response
|
||||
|
||||
|
||||
|
@ -53,3 +53,4 @@ async def cloudflare_deleterecord(domain: Domains, domain_id: str):
|
|||
cf_response = r.text
|
||||
except AssertionError:
|
||||
cf_response = "Error occured"
|
||||
return cf_response
|
||||
|
|
|
@ -128,6 +128,7 @@ async def get_addresses(wallet_ids: Union[str, List[str]]) -> List[Addresses]:
|
|||
|
||||
async def set_address_paid(payment_hash: str) -> Addresses:
|
||||
address = await get_address(payment_hash)
|
||||
assert address
|
||||
|
||||
if address.paid == False:
|
||||
await db.execute(
|
||||
|
@ -146,6 +147,7 @@ async def set_address_paid(payment_hash: str) -> Addresses:
|
|||
|
||||
async def set_address_renewed(address_id: str, duration: int):
|
||||
address = await get_address(address_id)
|
||||
assert address
|
||||
|
||||
extend_duration = int(address.duration) + duration
|
||||
await db.execute(
|
||||
|
|
|
@ -1,17 +1,9 @@
|
|||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from fastapi.params import Query
|
||||
from lnurl import ( # type: ignore
|
||||
LnurlErrorResponse,
|
||||
LnurlPayActionResponse,
|
||||
LnurlPayResponse,
|
||||
)
|
||||
from fastapi import Query, Request
|
||||
from lnurl import LnurlErrorResponse
|
||||
from loguru import logger
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from . import lnaddress_ext
|
||||
from .crud import get_address, get_address_by_username, get_domain
|
||||
|
@ -52,6 +44,7 @@ async def lnurl_callback(address_id, amount: int = Query(...)):
|
|||
amount_received = amount
|
||||
|
||||
domain = await get_domain(address.domain)
|
||||
assert domain
|
||||
|
||||
base_url = (
|
||||
address.wallet_endpoint[:-1]
|
||||
|
@ -79,7 +72,7 @@ async def lnurl_callback(address_id, amount: int = Query(...)):
|
|||
)
|
||||
|
||||
r = call.json()
|
||||
except AssertionError as e:
|
||||
except Exception:
|
||||
return LnurlErrorResponse(reason="ERROR")
|
||||
|
||||
# resp = LnurlPayActionResponse(pr=r["payment_request"], routes=[])
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi.params import Query
|
||||
from fastapi import Query
|
||||
from lnurl.types import LnurlPayMetadata
|
||||
from pydantic.main import BaseModel
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateDomain(BaseModel):
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
|
@ -21,7 +22,9 @@ async def wait_for_paid_invoices():
|
|||
async def call_webhook_on_paid(payment_hash):
|
||||
### Use webhook to notify about cloudflare registration
|
||||
address = await get_address(payment_hash)
|
||||
assert address
|
||||
domain = await get_domain(address.domain)
|
||||
assert domain
|
||||
|
||||
if not domain.webhook:
|
||||
return
|
||||
|
@ -39,24 +42,23 @@ async def call_webhook_on_paid(payment_hash):
|
|||
},
|
||||
timeout=40,
|
||||
)
|
||||
except AssertionError:
|
||||
webhook = None
|
||||
r.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.error(f"lnaddress: error calling webhook on paid: {str(e)}")
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if payment.extra.get("tag") == "lnaddress":
|
||||
|
||||
if payment.extra.get("tag") == "lnaddress":
|
||||
await payment.set_pending(False)
|
||||
await set_address_paid(payment_hash=payment.payment_hash)
|
||||
await call_webhook_on_paid(payment_hash=payment.payment_hash)
|
||||
|
||||
elif payment.extra.get("tag") == "renew lnaddress":
|
||||
|
||||
await payment.set_pending(False)
|
||||
await set_address_renewed(
|
||||
address_id=payment.extra["id"], duration=payment.extra["duration"]
|
||||
)
|
||||
await call_webhook_on_paid(payment_hash=payment.payment_hash)
|
||||
|
||||
else:
|
||||
return
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
from http import HTTPStatus
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
|
@ -35,6 +33,7 @@ async def display(domain_id, request: Request):
|
|||
await purge_addresses(domain_id)
|
||||
|
||||
wallet = await get_wallet(domain.wallet)
|
||||
assert wallet
|
||||
url = urlparse(str(request.url))
|
||||
|
||||
return lnaddress_renderer().TemplateResponse(
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
from http import HTTPStatus
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.params import Depends, Query
|
||||
from starlette.exceptions import HTTPException
|
||||
from fastapi import Depends, HTTPException, Query, Request
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.services import check_transaction_status, create_invoice
|
||||
|
@ -11,7 +9,7 @@ from lnbits.decorators import WalletTypeInfo, get_key_type
|
|||
from lnbits.extensions.lnaddress.models import CreateAddress, CreateDomain
|
||||
|
||||
from . import lnaddress_ext
|
||||
from .cloudflare import cloudflare_create_record, cloudflare_deleterecord
|
||||
from .cloudflare import cloudflare_create_record
|
||||
from .crud import (
|
||||
check_address_available,
|
||||
create_address,
|
||||
|
@ -35,7 +33,8 @@ async def api_domains(
|
|||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
user = await get_user(g.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
return [domain.dict() for domain in await get_domains(wallet_ids)]
|
||||
|
||||
|
@ -69,7 +68,7 @@ async def api_domain_create(
|
|||
|
||||
cf_response = await cloudflare_create_record(domain=domain, ip=root_url)
|
||||
|
||||
if not cf_response or cf_response["success"] != True:
|
||||
if not cf_response or not cf_response["success"]:
|
||||
await delete_domain(domain.id)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
|
@ -106,7 +105,8 @@ async def api_addresses(
|
|||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
user = await get_user(g.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
return [address.dict() for address in await get_addresses(wallet_ids)]
|
||||
|
||||
|
@ -227,7 +227,9 @@ async def api_lnaddress_make_address(
|
|||
@lnaddress_ext.get("/api/v1/addresses/{payment_hash}")
|
||||
async def api_address_send_address(payment_hash):
|
||||
address = await get_address(payment_hash)
|
||||
assert address
|
||||
domain = await get_domain(address.domain)
|
||||
assert domain
|
||||
try:
|
||||
status = await check_transaction_status(domain.wallet, payment_hash)
|
||||
is_paid = not status.pending
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from fastapi import Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi import Depends, Request
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import asyncio
|
||||
import time
|
||||
from base64 import urlsafe_b64encode
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
from fastapi import Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.crud import delete_expired_invoices, get_payments
|
||||
from lnbits.core.crud import get_payments
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from lnbits.decorators import WalletTypeInfo
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
|
@ -73,13 +71,13 @@ async def lndhub_addinvoice(
|
|||
}
|
||||
|
||||
|
||||
class Invoice(BaseModel):
|
||||
class CreateInvoice(BaseModel):
|
||||
invoice: str = Query(...)
|
||||
|
||||
|
||||
@lndhub_ext.post("/ext/payinvoice")
|
||||
async def lndhub_payinvoice(
|
||||
r_invoice: Invoice, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
r_invoice: CreateInvoice, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
try:
|
||||
await pay_invoice(
|
||||
|
|
|
@ -19,7 +19,7 @@ async def wait_for_paid_invoices():
|
|||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if not payment.extra or payment.extra.get("tag") != "lnticket":
|
||||
if payment.extra.get("tag") != "lnticket":
|
||||
# not a lnticket invoice
|
||||
return
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from typing import List, Optional, Union
|
||||
|
||||
import shortuuid
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
|
@ -12,7 +14,7 @@ async def create_lnurldevice(
|
|||
data: createLnurldevice,
|
||||
) -> lnurldevices:
|
||||
if data.device == "pos" or data.device == "atm":
|
||||
lnurldevice_id = str(await get_lnurldeviceposcount())
|
||||
lnurldevice_id = shortuuid.uuid()[:5]
|
||||
else:
|
||||
lnurldevice_id = urlsafe_short_hash()
|
||||
lnurldevice_key = urlsafe_short_hash()
|
||||
|
@ -82,17 +84,6 @@ async def update_lnurldevice(lnurldevice_id: str, **kwargs) -> Optional[lnurldev
|
|||
return lnurldevices(**row) if row else None
|
||||
|
||||
|
||||
async def get_lnurldeviceposcount() -> int:
|
||||
row = await db.fetchall(
|
||||
"SELECT * FROM lnurldevice.lnurldevices WHERE device = ? OR device = ?",
|
||||
(
|
||||
"pos",
|
||||
"atm",
|
||||
),
|
||||
)
|
||||
return len(row) + 1
|
||||
|
||||
|
||||
async def get_lnurldevice(lnurldevice_id: str) -> lnurldevices:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,)
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.db import SQLITE
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import CreatePayLinkData, PayLink
|
||||
|
||||
|
||||
async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
||||
link_id = urlsafe_short_hash()[:6]
|
||||
|
||||
returning = "" if db.type == SQLITE else "RETURNING ID"
|
||||
method = db.execute if db.type == SQLITE else db.fetchone
|
||||
|
||||
result = await (method)(
|
||||
result = await db.execute(
|
||||
f"""
|
||||
INSERT INTO lnurlp.pay_links (
|
||||
id,
|
||||
wallet,
|
||||
description,
|
||||
min,
|
||||
|
@ -29,10 +28,10 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
|||
currency,
|
||||
fiat_base_multiplier
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
{returning}
|
||||
VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
link_id,
|
||||
wallet_id,
|
||||
data.description,
|
||||
data.min,
|
||||
|
@ -47,17 +46,13 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
|||
data.fiat_base_multiplier,
|
||||
),
|
||||
)
|
||||
if db.type == SQLITE:
|
||||
link_id = result._result_proxy.lastrowid
|
||||
else:
|
||||
link_id = result[0]
|
||||
|
||||
link = await get_pay_link(link_id)
|
||||
assert link, "Newly created link couldn't be retrieved"
|
||||
return link
|
||||
|
||||
|
||||
async def get_pay_link(link_id: int) -> Optional[PayLink]:
|
||||
async def get_pay_link(link_id: str) -> Optional[PayLink]:
|
||||
row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
|
||||
return PayLink.from_row(row) if row else None
|
||||
|
||||
|
|
|
@ -68,3 +68,76 @@ async def m005_webhook_headers_and_body(db):
|
|||
"""
|
||||
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_headers TEXT;")
|
||||
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_body TEXT;")
|
||||
|
||||
|
||||
async def m006_redux(db):
|
||||
"""
|
||||
Add UUID ID's to links and migrates existing data
|
||||
"""
|
||||
await db.execute("ALTER TABLE lnurlp.pay_links RENAME TO pay_links_old")
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE lnurlp.pay_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
min INTEGER NOT NULL,
|
||||
max INTEGER,
|
||||
currency TEXT,
|
||||
fiat_base_multiplier INTEGER DEFAULT 1,
|
||||
served_meta INTEGER NOT NULL,
|
||||
served_pr INTEGER NOT NULL,
|
||||
webhook_url TEXT,
|
||||
success_text TEXT,
|
||||
success_url TEXT,
|
||||
comment_chars INTEGER DEFAULT 0,
|
||||
webhook_headers TEXT,
|
||||
webhook_body TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
for row in [
|
||||
list(row) for row in await db.fetchall("SELECT * FROM lnurlp.pay_links_old")
|
||||
]:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO lnurlp.pay_links (
|
||||
id,
|
||||
wallet,
|
||||
description,
|
||||
min,
|
||||
served_meta,
|
||||
served_pr,
|
||||
webhook_url,
|
||||
success_text,
|
||||
success_url,
|
||||
currency,
|
||||
comment_chars,
|
||||
max,
|
||||
fiat_base_multiplier,
|
||||
webhook_headers,
|
||||
webhook_body
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row[0],
|
||||
row[1],
|
||||
row[2],
|
||||
row[3],
|
||||
row[4],
|
||||
row[5],
|
||||
row[6],
|
||||
row[7],
|
||||
row[8],
|
||||
row[9],
|
||||
row[10],
|
||||
row[11],
|
||||
row[12],
|
||||
row[13],
|
||||
row[14],
|
||||
),
|
||||
)
|
||||
|
||||
await db.execute("DROP TABLE lnurlp.pay_links_old")
|
||||
|
|
|
@ -26,7 +26,7 @@ class CreatePayLinkData(BaseModel):
|
|||
|
||||
|
||||
class PayLink(BaseModel):
|
||||
id: int
|
||||
id: str
|
||||
wallet: str
|
||||
description: str
|
||||
min: float
|
||||
|
|
|
@ -4,7 +4,6 @@ import json
|
|||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.crud import update_payment_extra
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
|
@ -22,9 +21,8 @@ async def wait_for_paid_invoices():
|
|||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
async def on_invoice_paid(payment: Payment):
|
||||
if payment.extra.get("tag") != "lnurlp":
|
||||
# not an lnurlp invoice
|
||||
return
|
||||
|
||||
if payment.extra.get("wh_status"):
|
||||
|
@ -35,22 +33,23 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
if pay_link and pay_link.webhook_url:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
kwargs = {
|
||||
"json": {
|
||||
r: httpx.Response = await client.post(
|
||||
pay_link.webhook_url,
|
||||
json={
|
||||
"payment_hash": payment.payment_hash,
|
||||
"payment_request": payment.bolt11,
|
||||
"amount": payment.amount,
|
||||
"comment": payment.extra.get("comment"),
|
||||
"lnurlp": pay_link.id,
|
||||
"body": json.loads(pay_link.webhook_body)
|
||||
if pay_link.webhook_body
|
||||
else "",
|
||||
},
|
||||
"timeout": 40,
|
||||
}
|
||||
if pay_link.webhook_body:
|
||||
kwargs["json"]["body"] = json.loads(pay_link.webhook_body)
|
||||
if pay_link.webhook_headers:
|
||||
kwargs["headers"] = json.loads(pay_link.webhook_headers)
|
||||
|
||||
r: httpx.Response = await client.post(pay_link.webhook_url, **kwargs)
|
||||
headers=json.loads(pay_link.webhook_headers)
|
||||
if pay_link.webhook_headers
|
||||
else None,
|
||||
timeout=40,
|
||||
)
|
||||
await mark_webhook_sent(
|
||||
payment.payment_hash,
|
||||
r.status_code,
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
from fastapi import Depends, Query, Request
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
|
@ -36,7 +34,8 @@ async def api_links(
|
|||
wallet_ids = [wallet.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
try:
|
||||
return [
|
||||
|
@ -137,6 +136,7 @@ async def api_link_create_or_update(
|
|||
link = await update_pay_link(**data.dict(), link_id=link_id)
|
||||
else:
|
||||
link = await create_pay_link(data, wallet_id=wallet.wallet.id)
|
||||
assert link
|
||||
return {**link.dict(), "lnurl": link.lnurl(request)}
|
||||
|
||||
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
# LNURLPayOut
|
||||
|
||||
## Auto-dump a wallets funds to an LNURLpay
|
|
@ -1,25 +0,0 @@
|
|||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_lnurlpayout")
|
||||
|
||||
lnurlpayout_ext: APIRouter = APIRouter(prefix="/lnurlpayout", tags=["lnurlpayout"])
|
||||
|
||||
|
||||
def lnurlpayout_renderer():
|
||||
return template_renderer(["lnbits/extensions/lnurlpayout/templates"])
|
||||
|
||||
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def lnurlpayout_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "LNURLPayout",
|
||||
"short_description": "Autodump wallet funds to LNURLpay",
|
||||
"icon": "exit_to_app",
|
||||
"contributors": ["arcbtc","talvasconcelos"]
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import CreateLnurlPayoutData, lnurlpayout
|
||||
|
||||
|
||||
async def create_lnurlpayout(
|
||||
wallet_id: str, admin_key: str, data: CreateLnurlPayoutData
|
||||
) -> lnurlpayout:
|
||||
lnurlpayout_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO lnurlpayout.lnurlpayouts (id, title, wallet, admin_key, lnurlpay, threshold)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
lnurlpayout_id,
|
||||
data.title,
|
||||
wallet_id,
|
||||
admin_key,
|
||||
data.lnurlpay,
|
||||
data.threshold,
|
||||
),
|
||||
)
|
||||
|
||||
lnurlpayout = await get_lnurlpayout(lnurlpayout_id)
|
||||
assert lnurlpayout, "Newly created lnurlpayout couldn't be retrieved"
|
||||
return lnurlpayout
|
||||
|
||||
|
||||
async def get_lnurlpayout(lnurlpayout_id: str) -> Optional[lnurlpayout]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurlpayout.lnurlpayouts WHERE id = ?", (lnurlpayout_id,)
|
||||
)
|
||||
return lnurlpayout(**row) if row else None
|
||||
|
||||
|
||||
async def get_lnurlpayout_from_wallet(wallet_id: str) -> Optional[lnurlpayout]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurlpayout.lnurlpayouts WHERE wallet = ?", (wallet_id,)
|
||||
)
|
||||
return lnurlpayout(**row) if row else None
|
||||
|
||||
|
||||
async def get_lnurlpayouts(wallet_ids: Union[str, List[str]]) -> List[lnurlpayout]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM lnurlpayout.lnurlpayouts WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [lnurlpayout(**row) if row else None for row in rows]
|
||||
|
||||
|
||||
async def delete_lnurlpayout(lnurlpayout_id: str) -> None:
|
||||
await db.execute(
|
||||
"DELETE FROM lnurlpayout.lnurlpayouts WHERE id = ?", (lnurlpayout_id,)
|
||||
)
|
|
@ -1,16 +0,0 @@
|
|||
async def m001_initial(db):
|
||||
"""
|
||||
Initial lnurlpayouts table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE lnurlpayout.lnurlpayouts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
admin_key TEXT NOT NULL,
|
||||
lnurlpay TEXT NOT NULL,
|
||||
threshold {db.big_int} NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
|
@ -1,18 +0,0 @@
|
|||
from sqlite3 import Row
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateLnurlPayoutData(BaseModel):
|
||||
title: str
|
||||
lnurlpay: str
|
||||
threshold: int
|
||||
|
||||
|
||||
class lnurlpayout(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
wallet: str
|
||||
admin_key: str
|
||||
lnurlpay: str
|
||||
threshold: int
|
|
@ -1,91 +0,0 @@
|
|||
import asyncio
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import pay_invoice
|
||||
from lnbits.core.views.api import api_payments_decode
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import get_lnurlpayout_from_wallet
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, get_current_extension_name())
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
try:
|
||||
# Check its got a payout associated with it
|
||||
lnurlpayout_link = await get_lnurlpayout_from_wallet(payment.wallet_id)
|
||||
logger.debug("LNURLpayout", lnurlpayout_link)
|
||||
if lnurlpayout_link:
|
||||
|
||||
# Check the wallet balance is more than the threshold
|
||||
|
||||
wallet = await get_wallet(lnurlpayout_link.wallet)
|
||||
threshold = lnurlpayout_link.threshold + (lnurlpayout_link.threshold * 0.02)
|
||||
|
||||
if wallet.balance < threshold:
|
||||
return
|
||||
# Get the invoice from the LNURL to pay
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
url = await api_payments_decode({"data": lnurlpayout_link.lnurlpay})
|
||||
if str(url["domain"])[0:4] != "http":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="LNURL broken"
|
||||
)
|
||||
|
||||
try:
|
||||
r = await client.get(str(url["domain"]), timeout=40)
|
||||
res = r.json()
|
||||
try:
|
||||
r = await client.get(
|
||||
res["callback"]
|
||||
+ "?amount="
|
||||
+ str(
|
||||
int((wallet.balance - wallet.balance * 0.02) * 1000)
|
||||
),
|
||||
timeout=40,
|
||||
)
|
||||
res = r.json()
|
||||
|
||||
if hasattr(res, "status") and res["status"] == "ERROR":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail=res["reason"],
|
||||
)
|
||||
try:
|
||||
await pay_invoice(
|
||||
wallet_id=payment.wallet_id,
|
||||
payment_request=res["pr"],
|
||||
extra={"tag": "lnurlpayout"},
|
||||
)
|
||||
return
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print("ERROR", str(e))
|
||||
return
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
return
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Failed to save LNURLPayout",
|
||||
)
|
||||
except:
|
||||
return
|
|
@ -1,119 +0,0 @@
|
|||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlpayout"></q-btn>
|
||||
<q-expansion-item group="api" dense expand-separator label="List lnurlpayout">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/lnurlpayout/api/v1/lnurlpayouts</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<lnurlpayout_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}lnurlpayout/api/v1/lnurlpayouts -H
|
||||
"X-Api-Key: <invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Create a lnurlpayout"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">POST</span>
|
||||
/lnurlpayout/api/v1/lnurlpayouts</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"name": <string>, "currency": <string*ie USD*>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"currency": <string>, "id": <string>, "name":
|
||||
<string>, "wallet": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}lnurlpayout/api/v1/lnurlpayouts -d
|
||||
'{"name": <string>, "currency": <string>}' -H
|
||||
"Content-type: application/json" -H "X-Api-Key: <admin_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete a lnurlpayout"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.base_url
|
||||
}}lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id> -H
|
||||
"X-Api-Key: <admin_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Check lnurlpayout"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<lnurlpayout_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id> -H
|
||||
"X-Api-Key: <invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
|
@ -1,271 +0,0 @@
|
|||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||
>New LNURLPayout</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">LNURLPayout</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="lnurlpayouts"
|
||||
row-key="id"
|
||||
:columns="lnurlpayoutsTable.columns"
|
||||
:pagination.sync="lnurlpayoutsTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label}}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<a
|
||||
class="text-secondary"
|
||||
v-if="col.label == 'LNURLPay'"
|
||||
@click="copyText(col.value)"
|
||||
><q-tooltip>Click to copy LNURL</q-tooltip>{{
|
||||
col.value.substring(0, 40) }}...</a
|
||||
>
|
||||
<div v-else-if="col.label == 'Threshold'">
|
||||
{{ col.value }} Sats
|
||||
</div>
|
||||
<div v-else>{{ col.value.substring(0, 40) }}</div>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deletelnurlpayout(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">
|
||||
{{SITE_TITLE}} LNURLPayout extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "lnurlpayout/_api_docs.html" %}
|
||||
<q-separator></q-separator>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="createlnurlpayout" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.title"
|
||||
label="Title"
|
||||
placeholder="Title"
|
||||
type="text"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.lnurlpay"
|
||||
label="LNURLPay"
|
||||
placeholder="LNURLPay"
|
||||
type="text"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.threshold"
|
||||
label="Threshold (100k sats max)"
|
||||
placeholder="Threshold"
|
||||
type="number"
|
||||
max="100000"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="formDialog.data.threshold == null"
|
||||
type="submit"
|
||||
>Create LNURLPayout</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
var maplnurlpayout = function (obj) {
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||
obj.lnurlpayout = ['/lnurlpayout/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
lnurlpayouts: [],
|
||||
lnurlpayoutsTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'title', align: 'left', label: 'Title', field: 'title'},
|
||||
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'},
|
||||
{
|
||||
name: 'lnurlpay',
|
||||
align: 'left',
|
||||
label: 'LNURLPay',
|
||||
field: 'lnurlpay'
|
||||
},
|
||||
{
|
||||
name: 'threshold',
|
||||
align: 'left',
|
||||
label: 'Threshold',
|
||||
field: 'threshold'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {}
|
||||
},
|
||||
getlnurlpayouts: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/lnurlpayout/api/v1/lnurlpayouts?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.lnurlpayouts = response.data.map(function (obj) {
|
||||
return maplnurlpayout(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
createlnurlpayout: function () {
|
||||
var data = {
|
||||
title: this.formDialog.data.title,
|
||||
lnurlpay: this.formDialog.data.lnurlpay,
|
||||
threshold: this.formDialog.data.threshold
|
||||
}
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/lnurlpayout/api/v1/lnurlpayouts',
|
||||
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
|
||||
.inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
console.log(data)
|
||||
self.lnurlpayouts.push(maplnurlpayout(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deletelnurlpayout: function (lnurlpayoutId) {
|
||||
var self = this
|
||||
var lnurlpayout = _.findWhere(this.lnurlpayouts, {id: lnurlpayoutId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this lnurlpayout?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/lnurlpayout/api/v1/lnurlpayouts/' + lnurlpayoutId,
|
||||
_.findWhere(self.g.user.wallets, {id: lnurlpayout.wallet})
|
||||
.adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.lnurlpayouts = _.reject(self.lnurlpayouts, function (obj) {
|
||||
return obj.id == lnurlpayoutId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(
|
||||
this.lnurlpayoutsTable.columns,
|
||||
this.lnurlpayouts
|
||||
)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getlnurlpayouts()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,22 +0,0 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import lnurlpayout_ext, lnurlpayout_renderer
|
||||
from .crud import get_lnurlpayout
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@lnurlpayout_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return lnurlpayout_renderer().TemplateResponse(
|
||||
"lnurlpayout/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
|
@ -1,118 +0,0 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_payments, get_user
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment, api_payments_decode
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
|
||||
from . import lnurlpayout_ext
|
||||
from .crud import (
|
||||
create_lnurlpayout,
|
||||
delete_lnurlpayout,
|
||||
get_lnurlpayout,
|
||||
get_lnurlpayout_from_wallet,
|
||||
get_lnurlpayouts,
|
||||
)
|
||||
from .models import CreateLnurlPayoutData, lnurlpayout
|
||||
from .tasks import on_invoice_paid
|
||||
|
||||
|
||||
@lnurlpayout_ext.get("/api/v1/lnurlpayouts", status_code=HTTPStatus.OK)
|
||||
async def api_lnurlpayouts(
|
||||
all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
|
||||
return [lnurlpayout.dict() for lnurlpayout in await get_lnurlpayouts(wallet_ids)]
|
||||
|
||||
|
||||
@lnurlpayout_ext.post("/api/v1/lnurlpayouts", status_code=HTTPStatus.CREATED)
|
||||
async def api_lnurlpayout_create(
|
||||
data: CreateLnurlPayoutData, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
if await get_lnurlpayout_from_wallet(wallet.wallet.id):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Wallet already has lnurlpayout set",
|
||||
)
|
||||
return
|
||||
url = await api_payments_decode({"data": data.lnurlpay})
|
||||
if "domain" not in url:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="LNURL could not be decoded"
|
||||
)
|
||||
return
|
||||
if str(url["domain"])[0:4] != "http":
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not valid LNURL")
|
||||
return
|
||||
lnurlpayout = await create_lnurlpayout(
|
||||
wallet_id=wallet.wallet.id, admin_key=wallet.wallet.adminkey, data=data
|
||||
)
|
||||
if not lnurlpayout:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Failed to save LNURLPayout"
|
||||
)
|
||||
return
|
||||
return lnurlpayout.dict()
|
||||
|
||||
|
||||
@lnurlpayout_ext.delete("/api/v1/lnurlpayouts/{lnurlpayout_id}")
|
||||
async def api_lnurlpayout_delete(
|
||||
lnurlpayout_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
lnurlpayout = await get_lnurlpayout(lnurlpayout_id)
|
||||
|
||||
if not lnurlpayout:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="lnurlpayout does not exist."
|
||||
)
|
||||
|
||||
if lnurlpayout.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Not your lnurlpayout."
|
||||
)
|
||||
|
||||
await delete_lnurlpayout(lnurlpayout_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@lnurlpayout_ext.get("/api/v1/lnurlpayouts/{lnurlpayout_id}", status_code=HTTPStatus.OK)
|
||||
async def api_lnurlpayout_check(
|
||||
lnurlpayout_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
lnurlpayout = await get_lnurlpayout(lnurlpayout_id)
|
||||
## THIS
|
||||
mock_payment = Payment(
|
||||
checking_id="mock",
|
||||
pending=False,
|
||||
amount=1,
|
||||
fee=1,
|
||||
time=0000,
|
||||
bolt11="mock",
|
||||
preimage="mock",
|
||||
payment_hash="mock",
|
||||
wallet_id=lnurlpayout.wallet,
|
||||
)
|
||||
## INSTEAD OF THIS
|
||||
# payments = await get_payments(
|
||||
# wallet_id=lnurlpayout.wallet, complete=True, pending=False, outgoing=True, incoming=True
|
||||
# )
|
||||
|
||||
result = await on_invoice_paid(mock_payment)
|
||||
return
|
||||
|
||||
|
||||
# get payouts func
|
||||
# lnurlpayouts = await get_lnurlpayouts(wallet_ids)
|
||||
# for lnurlpayout in lnurlpayouts:
|
||||
# payments = await get_payments(
|
||||
# wallet_id=lnurlpayout.wallet, complete=True, pending=False, outgoing=True, incoming=True
|
||||
# )
|
||||
# await on_invoice_paid(payments[0])
|
9
lnbits/extensions/market/README.md
Normal file
9
lnbits/extensions/market/README.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
<h1>Market</h1>
|
||||
<h2>A movable market stand</h2>
|
||||
Make a list of products to sell, point the list to an relay (or many), stack sats.
|
||||
Market is a movable market stand, for anon transactions. You then give permission for an relay to list those products. Delivery addresses are sent through the Lightning Network.
|
||||
<img src="https://i.imgur.com/P1tvBSG.png">
|
||||
|
||||
<h2>API endpoints</h2>
|
||||
|
||||
<code>curl -X GET http://YOUR-TOR-ADDRESS</code>
|
43
lnbits/extensions/market/__init__.py
Normal file
43
lnbits/extensions/market/__init__.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_market")
|
||||
|
||||
market_ext: APIRouter = APIRouter(prefix="/market", tags=["market"])
|
||||
|
||||
market_static_files = [
|
||||
{
|
||||
"path": "/market/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/market/static"),
|
||||
"name": "market_static",
|
||||
}
|
||||
]
|
||||
|
||||
# if 'nostradmin' not in LNBITS_ADMIN_EXTENSIONS:
|
||||
# @market_ext.get("/", response_class=HTMLResponse)
|
||||
# async def index(request: Request):
|
||||
# return template_renderer().TemplateResponse(
|
||||
# "error.html", {"request": request, "err": "Ask system admin to enable NostrAdmin!"}
|
||||
# )
|
||||
# else:
|
||||
|
||||
|
||||
def market_renderer():
|
||||
return template_renderer(["lnbits/extensions/market/templates"])
|
||||
# return template_renderer(["lnbits/extensions/market/templates"])
|
||||
|
||||
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def market_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
6
lnbits/extensions/market/config.json
Normal file
6
lnbits/extensions/market/config.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "Marketplace",
|
||||
"short_description": "Webshop/market on LNbits",
|
||||
"tile": "/market/static/images/bitcoin-shop.png",
|
||||
"contributors": ["benarc", "talvasconcelos"]
|
||||
}
|
492
lnbits/extensions/market/crud.py
Normal file
492
lnbits/extensions/market/crud.py
Normal file
|
@ -0,0 +1,492 @@
|
|||
from base64 import urlsafe_b64encode
|
||||
from typing import List, Optional, Union
|
||||
from uuid import uuid4
|
||||
|
||||
# from lnbits.db import open_ext_db
|
||||
from lnbits.db import SQLITE
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.settings import WALLET
|
||||
|
||||
from . import db
|
||||
from .models import (
|
||||
ChatMessage,
|
||||
CreateChatMessage,
|
||||
CreateMarket,
|
||||
CreateMarketStalls,
|
||||
Market,
|
||||
MarketSettings,
|
||||
OrderDetail,
|
||||
Orders,
|
||||
Products,
|
||||
Stalls,
|
||||
Zones,
|
||||
createOrder,
|
||||
createOrderDetails,
|
||||
createProduct,
|
||||
createStalls,
|
||||
createZones,
|
||||
)
|
||||
|
||||
###Products
|
||||
|
||||
|
||||
async def create_market_product(data: createProduct) -> Products:
|
||||
product_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
f"""
|
||||
INSERT INTO market.products (id, stall, product, categories, description, image, price, quantity)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
product_id,
|
||||
data.stall,
|
||||
data.product,
|
||||
data.categories,
|
||||
data.description,
|
||||
data.image,
|
||||
data.price,
|
||||
data.quantity,
|
||||
),
|
||||
)
|
||||
product = await get_market_product(product_id)
|
||||
assert product, "Newly created product couldn't be retrieved"
|
||||
return product
|
||||
|
||||
|
||||
async def update_market_product(product_id: str, **kwargs) -> Optional[Products]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
await db.execute(
|
||||
f"UPDATE market.products SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), product_id),
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM market.products WHERE id = ?", (product_id,))
|
||||
|
||||
return Products(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_product(product_id: str) -> Optional[Products]:
|
||||
row = await db.fetchone("SELECT * FROM market.products WHERE id = ?", (product_id,))
|
||||
return Products(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_products(stall_ids: Union[str, List[str]]) -> List[Products]:
|
||||
if isinstance(stall_ids, str):
|
||||
stall_ids = [stall_ids]
|
||||
|
||||
# with open_ext_db("market") as db:
|
||||
q = ",".join(["?"] * len(stall_ids))
|
||||
rows = await db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM market.products WHERE stall IN ({q})
|
||||
""",
|
||||
(*stall_ids,),
|
||||
)
|
||||
return [Products(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_market_product(product_id: str) -> None:
|
||||
await db.execute("DELETE FROM market.products WHERE id = ?", (product_id,))
|
||||
|
||||
|
||||
###zones
|
||||
|
||||
|
||||
async def create_market_zone(user, data: createZones) -> Zones:
|
||||
zone_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
f"""
|
||||
INSERT INTO market.zones (
|
||||
id,
|
||||
"user",
|
||||
cost,
|
||||
countries
|
||||
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(zone_id, user, data.cost, data.countries.lower()),
|
||||
)
|
||||
|
||||
zone = await get_market_zone(zone_id)
|
||||
assert zone, "Newly created zone couldn't be retrieved"
|
||||
return zone
|
||||
|
||||
|
||||
async def update_market_zone(zone_id: str, **kwargs) -> Optional[Zones]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE market.zones SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), zone_id),
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM market.zones WHERE id = ?", (zone_id,))
|
||||
return Zones(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_zone(zone_id: str) -> Optional[Zones]:
|
||||
row = await db.fetchone("SELECT * FROM market.zones WHERE id = ?", (zone_id,))
|
||||
return Zones(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_zones(user: str) -> List[Zones]:
|
||||
rows = await db.fetchall('SELECT * FROM market.zones WHERE "user" = ?', (user,))
|
||||
return [Zones(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_market_zone(zone_id: str) -> None:
|
||||
await db.execute("DELETE FROM market.zones WHERE id = ?", (zone_id,))
|
||||
|
||||
|
||||
###Stalls
|
||||
|
||||
|
||||
async def create_market_stall(data: createStalls) -> Stalls:
|
||||
stall_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
f"""
|
||||
INSERT INTO market.stalls (
|
||||
id,
|
||||
wallet,
|
||||
name,
|
||||
currency,
|
||||
publickey,
|
||||
relays,
|
||||
shippingzones
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
stall_id,
|
||||
data.wallet,
|
||||
data.name,
|
||||
data.currency,
|
||||
data.publickey,
|
||||
data.relays,
|
||||
data.shippingzones,
|
||||
),
|
||||
)
|
||||
|
||||
stall = await get_market_stall(stall_id)
|
||||
assert stall, "Newly created stall couldn't be retrieved"
|
||||
return stall
|
||||
|
||||
|
||||
async def update_market_stall(stall_id: str, **kwargs) -> Optional[Stalls]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE market.stalls SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), stall_id),
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM market.stalls WHERE id = ?", (stall_id,))
|
||||
return Stalls(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_stall(stall_id: str) -> Optional[Stalls]:
|
||||
row = await db.fetchone("SELECT * FROM market.stalls WHERE id = ?", (stall_id,))
|
||||
return Stalls(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_stalls(wallet_ids: Union[str, List[str]]) -> List[Stalls]:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM market.stalls WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Stalls(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_market_stalls_by_ids(stall_ids: Union[str, List[str]]) -> List[Stalls]:
|
||||
q = ",".join(["?"] * len(stall_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM market.stalls WHERE id IN ({q})", (*stall_ids,)
|
||||
)
|
||||
return [Stalls(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_market_stall(stall_id: str) -> None:
|
||||
await db.execute("DELETE FROM market.stalls WHERE id = ?", (stall_id,))
|
||||
|
||||
|
||||
###Orders
|
||||
|
||||
|
||||
async def create_market_order(data: createOrder, invoiceid: str):
|
||||
returning = "" if db.type == SQLITE else "RETURNING ID"
|
||||
method = db.execute if db.type == SQLITE else db.fetchone
|
||||
|
||||
result = await (method)(
|
||||
f"""
|
||||
INSERT INTO market.orders (wallet, shippingzone, address, email, total, invoiceid, paid, shipped)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
{returning}
|
||||
""",
|
||||
(
|
||||
data.wallet,
|
||||
data.shippingzone,
|
||||
data.address,
|
||||
data.email,
|
||||
data.total,
|
||||
invoiceid,
|
||||
False,
|
||||
False,
|
||||
),
|
||||
)
|
||||
if db.type == SQLITE:
|
||||
return result._result_proxy.lastrowid
|
||||
else:
|
||||
return result[0]
|
||||
|
||||
|
||||
async def create_market_order_details(order_id: str, data: List[createOrderDetails]):
|
||||
for item in data:
|
||||
item_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO market.order_details (id, order_id, product_id, quantity)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
item_id,
|
||||
order_id,
|
||||
item.product_id,
|
||||
item.quantity,
|
||||
),
|
||||
)
|
||||
order_details = await get_market_order_details(order_id)
|
||||
return order_details
|
||||
|
||||
|
||||
async def get_market_order_details(order_id: str) -> List[OrderDetail]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM market.order_details WHERE order_id = ?", (order_id,)
|
||||
)
|
||||
|
||||
return [OrderDetail(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_market_order(order_id: str) -> Optional[Orders]:
|
||||
row = await db.fetchone("SELECT * FROM market.orders WHERE id = ?", (order_id,))
|
||||
return Orders(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_order_invoiceid(invoice_id: str) -> Optional[Orders]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM market.orders WHERE invoiceid = ?", (invoice_id,)
|
||||
)
|
||||
return Orders(**row) if row else None
|
||||
|
||||
|
||||
async def set_market_order_paid(payment_hash: str):
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE market.orders
|
||||
SET paid = true
|
||||
WHERE invoiceid = ?
|
||||
""",
|
||||
(payment_hash,),
|
||||
)
|
||||
|
||||
|
||||
async def set_market_order_pubkey(payment_hash: str, pubkey: str):
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE market.orders
|
||||
SET pubkey = ?
|
||||
WHERE invoiceid = ?
|
||||
""",
|
||||
(
|
||||
pubkey,
|
||||
payment_hash,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def update_market_product_stock(products):
|
||||
|
||||
q = "\n".join(
|
||||
[f"""WHEN id='{p.product_id}' THEN quantity - {p.quantity}""" for p in products]
|
||||
)
|
||||
v = ",".join(["?"] * len(products))
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE market.products
|
||||
SET quantity=(CASE
|
||||
{q}
|
||||
END)
|
||||
WHERE id IN ({v});
|
||||
""",
|
||||
(*[p.product_id for p in products],),
|
||||
)
|
||||
|
||||
|
||||
async def get_market_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM market.orders WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
#
|
||||
return [Orders(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_market_order(order_id: str) -> None:
|
||||
await db.execute("DELETE FROM market.orders WHERE id = ?", (order_id,))
|
||||
|
||||
|
||||
### Market/Marketplace
|
||||
|
||||
|
||||
async def get_market_markets(user: str) -> List[Market]:
|
||||
rows = await db.fetchall("SELECT * FROM market.markets WHERE usr = ?", (user,))
|
||||
return [Market(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_market_market(market_id: str) -> Optional[Market]:
|
||||
row = await db.fetchone("SELECT * FROM market.markets WHERE id = ?", (market_id,))
|
||||
return Market(**row) if row else None
|
||||
|
||||
|
||||
async def get_market_market_stalls(market_id: str):
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM market.market_stalls WHERE marketid = ?", (market_id,)
|
||||
)
|
||||
|
||||
ids = [row["stallid"] for row in rows]
|
||||
|
||||
return await get_market_stalls_by_ids(ids)
|
||||
|
||||
|
||||
async def create_market_market(data: CreateMarket):
|
||||
market_id = urlsafe_short_hash()
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO market.markets (id, usr, name)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
market_id,
|
||||
data.usr,
|
||||
data.name,
|
||||
),
|
||||
)
|
||||
market = await get_market_market(market_id)
|
||||
assert market, "Newly created market couldn't be retrieved"
|
||||
return market
|
||||
|
||||
|
||||
async def create_market_market_stalls(market_id: str, data: List[str]):
|
||||
for stallid in data:
|
||||
id = urlsafe_short_hash()
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO market.market_stalls (id, marketid, stallid)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
id,
|
||||
market_id,
|
||||
stallid,
|
||||
),
|
||||
)
|
||||
market_stalls = await get_market_market_stalls(market_id)
|
||||
return market_stalls
|
||||
|
||||
|
||||
async def update_market_market(market_id: str, name: str):
|
||||
await db.execute(
|
||||
"UPDATE market.markets SET name = ? WHERE id = ?",
|
||||
(name, market_id),
|
||||
)
|
||||
await db.execute(
|
||||
"DELETE FROM market.market_stalls WHERE marketid = ?",
|
||||
(market_id,),
|
||||
)
|
||||
|
||||
market = await get_market_market(market_id)
|
||||
return market
|
||||
|
||||
|
||||
### CHAT / MESSAGES
|
||||
|
||||
|
||||
async def create_chat_message(data: CreateChatMessage):
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO market.messages (msg, pubkey, id_conversation)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
data.msg,
|
||||
data.pubkey,
|
||||
data.room_name,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def get_market_latest_chat_messages(room_name: str):
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM market.messages WHERE id_conversation = ? ORDER BY timestamp DESC LIMIT 20",
|
||||
(room_name,),
|
||||
)
|
||||
|
||||
return [ChatMessage(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_market_chat_messages(room_name: str):
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM market.messages WHERE id_conversation = ? ORDER BY timestamp DESC",
|
||||
(room_name,),
|
||||
)
|
||||
|
||||
return [ChatMessage(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_market_chat_by_merchant(ids: List[str]) -> List[ChatMessage]:
|
||||
|
||||
q = ",".join(["?"] * len(ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM market.messages WHERE id_conversation IN ({q})",
|
||||
(*ids,),
|
||||
)
|
||||
return [ChatMessage(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_market_settings(user) -> Optional[MarketSettings]:
|
||||
row = await db.fetchone(
|
||||
"""SELECT * FROM market.settings WHERE "user" = ?""", (user,)
|
||||
)
|
||||
|
||||
return MarketSettings(**row) if row else None
|
||||
|
||||
|
||||
async def create_market_settings(user: str, data):
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO market.settings ("user", currency, fiat_base_multiplier)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user,
|
||||
data.currency,
|
||||
data.fiat_base_multiplier,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def set_market_settings(user: str, data):
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE market.settings
|
||||
SET currency = ?, fiat_base_multiplier = ?
|
||||
WHERE "user" = ?;
|
||||
""",
|
||||
(
|
||||
data.currency,
|
||||
data.fiat_base_multiplier,
|
||||
user,
|
||||
),
|
||||
)
|
156
lnbits/extensions/market/migrations.py
Normal file
156
lnbits/extensions/market/migrations.py
Normal file
|
@ -0,0 +1,156 @@
|
|||
async def m001_initial(db):
|
||||
"""
|
||||
Initial Market settings table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE market.settings (
|
||||
"user" TEXT PRIMARY KEY,
|
||||
currency TEXT DEFAULT 'sat',
|
||||
fiat_base_multiplier INTEGER DEFAULT 1
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial stalls table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE market.stalls (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
currency TEXT,
|
||||
publickey TEXT,
|
||||
relays TEXT,
|
||||
shippingzones TEXT NOT NULL,
|
||||
rating INTEGER DEFAULT 0
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial products table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE market.products (
|
||||
id TEXT PRIMARY KEY,
|
||||
stall TEXT NOT NULL REFERENCES {db.references_schema}stalls (id) ON DELETE CASCADE,
|
||||
product TEXT NOT NULL,
|
||||
categories TEXT,
|
||||
description TEXT,
|
||||
image TEXT,
|
||||
price INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
rating INTEGER DEFAULT 0
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial zones table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE market.zones (
|
||||
id TEXT PRIMARY KEY,
|
||||
"user" TEXT NOT NULL,
|
||||
cost TEXT NOT NULL,
|
||||
countries TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial orders table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE market.orders (
|
||||
id {db.serial_primary_key},
|
||||
wallet TEXT NOT NULL,
|
||||
username TEXT,
|
||||
pubkey TEXT,
|
||||
shippingzone TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
total INTEGER NOT NULL,
|
||||
invoiceid TEXT NOT NULL,
|
||||
paid BOOLEAN NOT NULL,
|
||||
shipped BOOLEAN NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial order details table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE market.order_details (
|
||||
id TEXT PRIMARY KEY,
|
||||
order_id INTEGER NOT NULL REFERENCES {db.references_schema}orders (id) ON DELETE CASCADE,
|
||||
product_id TEXT NOT NULL REFERENCES {db.references_schema}products (id) ON DELETE CASCADE,
|
||||
quantity INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial market table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE market.markets (
|
||||
id TEXT PRIMARY KEY,
|
||||
usr TEXT NOT NULL,
|
||||
name TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial market stalls table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE market.market_stalls (
|
||||
id TEXT PRIMARY KEY,
|
||||
marketid TEXT NOT NULL REFERENCES {db.references_schema}markets (id) ON DELETE CASCADE,
|
||||
stallid TEXT NOT NULL REFERENCES {db.references_schema}stalls (id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial chat messages table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE market.messages (
|
||||
id {db.serial_primary_key},
|
||||
msg TEXT NOT NULL,
|
||||
pubkey TEXT NOT NULL,
|
||||
id_conversation TEXT NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
if db.type != "SQLITE":
|
||||
"""
|
||||
Create indexes for message fetching
|
||||
"""
|
||||
await db.execute(
|
||||
"CREATE INDEX idx_messages_timestamp ON market.messages (timestamp DESC)"
|
||||
)
|
||||
await db.execute(
|
||||
"CREATE INDEX idx_messages_conversations ON market.messages (id_conversation)"
|
||||
)
|
135
lnbits/extensions/market/models.py
Normal file
135
lnbits/extensions/market/models.py
Normal file
|
@ -0,0 +1,135 @@
|
|||
from typing import List, Optional
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MarketSettings(BaseModel):
|
||||
user: str
|
||||
currency: str
|
||||
fiat_base_multiplier: int
|
||||
|
||||
|
||||
class SetSettings(BaseModel):
|
||||
currency: str
|
||||
fiat_base_multiplier: int = Query(100, ge=1)
|
||||
|
||||
|
||||
class Stalls(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
name: str
|
||||
currency: str
|
||||
publickey: Optional[str]
|
||||
relays: Optional[str]
|
||||
shippingzones: str
|
||||
|
||||
|
||||
class createStalls(BaseModel):
|
||||
wallet: str = Query(...)
|
||||
name: str = Query(...)
|
||||
currency: str = Query("sat")
|
||||
publickey: str = Query(None)
|
||||
relays: str = Query(None)
|
||||
shippingzones: str = Query(...)
|
||||
|
||||
|
||||
class createProduct(BaseModel):
|
||||
stall: str = Query(...)
|
||||
product: str = Query(...)
|
||||
categories: str = Query(None)
|
||||
description: str = Query(None)
|
||||
image: str = Query(None)
|
||||
price: float = Query(0, ge=0)
|
||||
quantity: int = Query(0, ge=0)
|
||||
|
||||
|
||||
class Products(BaseModel):
|
||||
id: str
|
||||
stall: str
|
||||
product: str
|
||||
categories: Optional[str]
|
||||
description: Optional[str]
|
||||
image: Optional[str]
|
||||
price: float
|
||||
quantity: int
|
||||
|
||||
|
||||
class createZones(BaseModel):
|
||||
cost: float = Query(0, ge=0)
|
||||
countries: str = Query(...)
|
||||
|
||||
|
||||
class Zones(BaseModel):
|
||||
id: str
|
||||
user: str
|
||||
cost: float
|
||||
countries: str
|
||||
|
||||
|
||||
class OrderDetail(BaseModel):
|
||||
id: str
|
||||
order_id: str
|
||||
product_id: str
|
||||
quantity: int
|
||||
|
||||
|
||||
class createOrderDetails(BaseModel):
|
||||
product_id: str = Query(...)
|
||||
quantity: int = Query(..., ge=1)
|
||||
|
||||
|
||||
class createOrder(BaseModel):
|
||||
wallet: str = Query(...)
|
||||
username: str = Query(None)
|
||||
pubkey: str = Query(None)
|
||||
shippingzone: str = Query(...)
|
||||
address: str = Query(...)
|
||||
email: str = Query(...)
|
||||
total: int = Query(...)
|
||||
products: List[createOrderDetails]
|
||||
|
||||
|
||||
class Orders(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
username: Optional[str]
|
||||
pubkey: Optional[str]
|
||||
shippingzone: str
|
||||
address: str
|
||||
email: str
|
||||
total: int
|
||||
invoiceid: str
|
||||
paid: bool
|
||||
shipped: bool
|
||||
time: int
|
||||
|
||||
|
||||
class CreateMarket(BaseModel):
|
||||
usr: str = Query(...)
|
||||
name: str = Query(None)
|
||||
stalls: List[str] = Query(...)
|
||||
|
||||
|
||||
class Market(BaseModel):
|
||||
id: str
|
||||
usr: str
|
||||
name: Optional[str]
|
||||
|
||||
|
||||
class CreateMarketStalls(BaseModel):
|
||||
stallid: str
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
id: str
|
||||
msg: str
|
||||
pubkey: str
|
||||
id_conversation: str
|
||||
timestamp: int
|
||||
|
||||
|
||||
class CreateChatMessage(BaseModel):
|
||||
msg: str = Query(..., min_length=1)
|
||||
pubkey: str = Query(...)
|
||||
room_name: str = Query(...)
|
91
lnbits/extensions/market/notifier.py
Normal file
91
lnbits/extensions/market/notifier.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
## adapted from https://github.com/Sentymental/chat-fastapi-websocket
|
||||
"""
|
||||
Create a class Notifier that will handle messages
|
||||
and delivery to the specific person
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import WebSocket
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.extensions.market.crud import create_chat_message
|
||||
from lnbits.extensions.market.models import CreateChatMessage
|
||||
|
||||
|
||||
class Notifier:
|
||||
"""
|
||||
Manages chatrooms, sessions and members.
|
||||
|
||||
Methods:
|
||||
- get_notification_generator(self): async generator with notification messages
|
||||
- get_members(self, room_name: str): get members in room
|
||||
- push(message: str, room_name: str): push message
|
||||
- connect(websocket: WebSocket, room_name: str): connect to room
|
||||
- remove(websocket: WebSocket, room_name: str): remove
|
||||
- _notify(message: str, room_name: str): notifier
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Create sessions as a dict:
|
||||
self.sessions: dict = defaultdict(dict)
|
||||
|
||||
# Create notification generator:
|
||||
self.generator = self.get_notification_generator()
|
||||
|
||||
async def get_notification_generator(self):
|
||||
"""Notification Generator"""
|
||||
|
||||
while True:
|
||||
message = yield
|
||||
msg = message["message"]
|
||||
room_name = message["room_name"]
|
||||
await self._notify(msg, room_name)
|
||||
|
||||
def get_members(self, room_name: str):
|
||||
"""Get all members in a room"""
|
||||
|
||||
try:
|
||||
logger.info(f"Looking for members in room: {room_name}")
|
||||
return self.sessions[room_name]
|
||||
|
||||
except Exception:
|
||||
logger.exception(f"There is no member in room: {room_name}")
|
||||
return None
|
||||
|
||||
async def push(self, message: str, room_name: str = None):
|
||||
"""Push a message"""
|
||||
|
||||
message_body = {"message": message, "room_name": room_name}
|
||||
await self.generator.asend(message_body)
|
||||
|
||||
async def connect(self, websocket: WebSocket, room_name: str):
|
||||
"""Connect to room"""
|
||||
|
||||
await websocket.accept()
|
||||
if self.sessions[room_name] == {} or len(self.sessions[room_name]) == 0:
|
||||
self.sessions[room_name] = []
|
||||
|
||||
self.sessions[room_name].append(websocket)
|
||||
print(f"Connections ...: {self.sessions[room_name]}")
|
||||
|
||||
def remove(self, websocket: WebSocket, room_name: str):
|
||||
"""Remove websocket from room"""
|
||||
|
||||
self.sessions[room_name].remove(websocket)
|
||||
print(f"Connection removed...\nOpen connections...: {self.sessions[room_name]}")
|
||||
|
||||
async def _notify(self, message: str, room_name: str):
|
||||
"""Notifier"""
|
||||
d = json.loads(message)
|
||||
d["room_name"] = room_name
|
||||
db_msg = CreateChatMessage.parse_obj(d)
|
||||
await create_chat_message(data=db_msg)
|
||||
|
||||
remaining_sessions = []
|
||||
while len(self.sessions[room_name]) > 0:
|
||||
websocket = self.sessions[room_name].pop()
|
||||
await websocket.send_text(message)
|
||||
remaining_sessions.append(websocket)
|
||||
self.sessions[room_name] = remaining_sessions
|
BIN
lnbits/extensions/market/static/images/bitcoin-shop.png
Normal file
BIN
lnbits/extensions/market/static/images/bitcoin-shop.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.9 KiB |
BIN
lnbits/extensions/market/static/images/placeholder.png
Normal file
BIN
lnbits/extensions/market/static/images/placeholder.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.8 KiB |
39
lnbits/extensions/market/tasks.py
Normal file
39
lnbits/extensions/market/tasks.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
import asyncio
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import (
|
||||
get_market_order_details,
|
||||
get_market_order_invoiceid,
|
||||
set_market_order_paid,
|
||||
update_market_product_stock,
|
||||
)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue)
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if payment.extra.get("tag") != "market":
|
||||
return
|
||||
|
||||
order = await get_market_order_invoiceid(payment.payment_hash)
|
||||
if not order:
|
||||
logger.error("this should never happen", payment)
|
||||
return
|
||||
|
||||
# set order as paid
|
||||
await set_market_order_paid(payment.payment_hash)
|
||||
|
||||
# deduct items sold from stock
|
||||
details = await get_market_order_details(order.id)
|
||||
await update_market_product_stock(details)
|
128
lnbits/extensions/market/templates/market/_api_docs.html
Normal file
128
lnbits/extensions/market/templates/market/_api_docs.html
Normal file
|
@ -0,0 +1,128 @@
|
|||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Setup guide"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-my-none">
|
||||
LNbits Market (Nostr support coming soon)
|
||||
</h5>
|
||||
|
||||
<ol>
|
||||
<li>Create Shipping Zones you're willing to ship to</li>
|
||||
<li>Create a Stall to list yiur products on</li>
|
||||
<li>Create products to put on the Stall</li>
|
||||
<li>Take orders</li>
|
||||
<li>Includes chat support!</li>
|
||||
</ol>
|
||||
The first LNbits market idea 'Diagon Alley' helped create Nostr, and soon
|
||||
this market extension will have the option to work on Nostr 'Diagon Alley'
|
||||
mode, by the merchant, market, and buyer all having keys, and data being
|
||||
routed through Nostr relays.
|
||||
<br />
|
||||
<small>
|
||||
Created by,
|
||||
<a href="https://github.com/talvasconcelos">Tal Vasconcelos</a>,
|
||||
<a href="https://github.com/benarc">Ben Arc</a></small
|
||||
>
|
||||
<!-- </p> -->
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get prodcuts, categorised by wallet"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/market/api/v1/stall/products/<relay_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>Product JSON list</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}api/v1/stall/products/<relay_id></code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get invoice for product"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-green">POST</span>
|
||||
/market/api/v1/stall/order/<relay_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"id": <string>, "address": <string>, "shippingzone":
|
||||
<integer>, "email": <string>, "quantity":
|
||||
<integer>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"checking_id": <string>,"payment_request":
|
||||
<string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root
|
||||
}}api/v1/stall/order/<relay_id> -d '{"id": <product_id&>,
|
||||
"email": <customer_email>, "address": <customer_address>,
|
||||
"quantity": 2, "shippingzone": 1}' -H "Content-type: application/json"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Check a product has been shipped"
|
||||
class="q-mb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/market/api/v1/stall/checkshipped/<checking_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{"shipped": <boolean>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}api/v1/stall/checkshipped/<checking_id> -H "Content-type:
|
||||
application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
58
lnbits/extensions/market/templates/market/_chat_box.html
Normal file
58
lnbits/extensions/market/templates/market/_chat_box.html
Normal file
|
@ -0,0 +1,58 @@
|
|||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">Messages</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-select
|
||||
v-model="customerKey"
|
||||
:options="Object.keys(messages).map(k => ({label: `${k.slice(0, 25)}...`, value: k}))"
|
||||
label="Customers"
|
||||
@input="chatRoom(customerKey)"
|
||||
emit-value
|
||||
></q-select>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="chat-container" ref="chatCard">
|
||||
<div class="chat-box">
|
||||
<!-- <p v-if="Object.keys(messages).length === 0">No messages yet</p> -->
|
||||
<div class="chat-messages">
|
||||
<q-chat-message
|
||||
:key="index"
|
||||
v-for="(message, index) in orderMessages"
|
||||
:name="message.pubkey == keys.pubkey ? 'me' : 'customer'"
|
||||
:text="[message.msg]"
|
||||
:sent="message.pubkey == keys.pubkey ? true : false"
|
||||
:bg-color="message.pubkey == keys.pubkey ? 'white' : 'light-green-2'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<q-card-section>
|
||||
<q-form @submit="sendMessage" class="full-width chat-input">
|
||||
<q-input
|
||||
ref="newMessage"
|
||||
v-model="newMessage"
|
||||
placeholder="Message"
|
||||
class="full-width"
|
||||
dense
|
||||
outlined
|
||||
@click="checkWebSocket"
|
||||
>
|
||||
<template>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
type="submit"
|
||||
icon="send"
|
||||
color="primary"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
393
lnbits/extensions/market/templates/market/_dialogs.html
Normal file
393
lnbits/extensions/market/templates/market/_dialogs.html
Normal file
|
@ -0,0 +1,393 @@
|
|||
<!-- PRODUCT DIALOG -->
|
||||
<q-dialog v-model="productDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendProductFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="productDialog.data.stall"
|
||||
:options="stalls.map(s => ({label: s.name, value: s.id}))"
|
||||
label="Stall"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.product"
|
||||
label="Product"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.description"
|
||||
label="Description"
|
||||
></q-input>
|
||||
<!-- <div class="row"> -->
|
||||
<!-- <div class="col-5">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
multiple
|
||||
v-model.trim="productDialog.data.categories"
|
||||
:options="categories"
|
||||
label="Categories"
|
||||
class="q-pr-sm"
|
||||
></q-select>
|
||||
</div> -->
|
||||
<!-- <div class="col-7"> -->
|
||||
<q-select
|
||||
filled
|
||||
multiple
|
||||
dense
|
||||
emit-value
|
||||
v-model.trim="productDialog.data.categories"
|
||||
use-input
|
||||
use-chips
|
||||
multiple
|
||||
hide-dropdown-icon
|
||||
input-debounce="0"
|
||||
new-value-mode="add-unique"
|
||||
label="Categories"
|
||||
placeholder="crafts,robots,etc"
|
||||
hint="Hit Enter to add"
|
||||
></q-select>
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
|
||||
<q-file
|
||||
class="q-pr-md"
|
||||
filled
|
||||
dense
|
||||
capture="environment"
|
||||
accept="image/jpeg, image/png"
|
||||
:max-file-size="3*1024**2"
|
||||
label="Small image (optional)"
|
||||
clearable
|
||||
@input="imageAdded"
|
||||
@clear="imageCleared"
|
||||
>
|
||||
<template v-if="productDialog.data.image" v-slot:before>
|
||||
<img style="height: 1em" :src="productDialog.data.image" />
|
||||
</template>
|
||||
<template v-if="productDialog.data.image" v-slot:append>
|
||||
<q-icon
|
||||
name="cancel"
|
||||
@click.stop.prevent="imageCleared"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</q-file>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="productDialog.data.price"
|
||||
type="number"
|
||||
:label="'Price (' + currencies.unit + ') *'"
|
||||
:mask="currencies.unit != 'sat' ? '#.##' : '#'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="currencies.unit != 'sat' ? '0.01' : '1'"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="productDialog.data.quantity"
|
||||
type="number"
|
||||
label="Quantity"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="productDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Product</q-btn
|
||||
>
|
||||
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="productDialog.data.price == null
|
||||
|| productDialog.data.product == null
|
||||
|| productDialog.data.description == null
|
||||
|| productDialog.data.quantity == null"
|
||||
type="submit"
|
||||
>Create Product</q-btn
|
||||
>
|
||||
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@click="resetDialog('productDialog')"
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- ZONE DIALOG -->
|
||||
<q-dialog v-model="zoneDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendZoneFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
multiple
|
||||
:options="shippingZoneOptions"
|
||||
label="Countries"
|
||||
v-model.trim="zoneDialog.data.countries"
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
:label="'Amount (' + currencies.unit + ') *'"
|
||||
:mask="currencies.unit != 'sat' ? '#.##' : '#'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="currencies.unit != 'sat' ? '0.01' : '1'"
|
||||
type="number"
|
||||
v-model.trim="zoneDialog.data.cost"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="zoneDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Shipping Zone</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="zoneDialog.data.countries == null
|
||||
|| zoneDialog.data.cost == null"
|
||||
type="submit"
|
||||
>Create Shipping Zone</q-btn
|
||||
>
|
||||
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@click="resetDialog('zoneDialog')"
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- MARKETPLACE/market DIALOG -->
|
||||
<q-dialog v-model="marketDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendMarketplaceFormData" class="q-gutter-md">
|
||||
<q-toggle
|
||||
label="Activate marketplace"
|
||||
color="primary"
|
||||
v-model="marketDialog.data.activate"
|
||||
></q-toggle>
|
||||
<q-select
|
||||
filled
|
||||
multiple
|
||||
emit-value
|
||||
:options="stalls.map(s => ({label: s.name, value: s.id}))"
|
||||
label="Stalls"
|
||||
v-model="marketDialog.data.stalls"
|
||||
map-options
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="marketDialog.data.name"
|
||||
label="Name"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="marketDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Marketplace</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="marketDialog.data.activate == null
|
||||
|| marketDialog.data.stalls == null"
|
||||
type="submit"
|
||||
>Launch Marketplace</q-btn
|
||||
>
|
||||
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@click="resetDialog('marketDialog')"
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- STALL/STORE DIALOG -->
|
||||
<q-dialog v-model="stallDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendStallFormData" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="stallDialog.data.name"
|
||||
label="Name"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="stallDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
v-if="diagonAlley"
|
||||
v-if="keys"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="stallDialog.data.publickey"
|
||||
label="Public Key"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-if="diagonAlley"
|
||||
v-if="keys"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="stallDialog.data.privatekey"
|
||||
label="Private Key"
|
||||
></q-input>
|
||||
<!-- NOSTR -->
|
||||
<div v-if="diagonAlley" class="row">
|
||||
<div class="col-5">
|
||||
<q-btn unelevated @click="generateKeys" color="primary"
|
||||
>Generate keys</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<q-btn unelevated @click="restoreKeys" color="primary"
|
||||
>Restore keys</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-select
|
||||
:options="zoneOptions"
|
||||
filled
|
||||
dense
|
||||
multiple
|
||||
v-model.trim="stallDialog.data.shippingzones"
|
||||
label="Shipping Zones"
|
||||
></q-select>
|
||||
<q-select
|
||||
v-if="diagonAlley"
|
||||
:options="relayOptions"
|
||||
filled
|
||||
dense
|
||||
multiple
|
||||
v-model.trim="stallDialog.data.relays"
|
||||
label="Relays"
|
||||
></q-select>
|
||||
<q-input
|
||||
v-if="diagonAlley"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="stallDialog.data.crelays"
|
||||
label="Custom relays (seperate by comma)"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-if="diagonAlley"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="stallDialog.data.nostrMarkets"
|
||||
label="Nostr market public keys (seperate by comma)"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="stallDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Stall</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="stallDialog.data.wallet == null
|
||||
|| stallDialog.data.shippingzones == null"
|
||||
type="submit"
|
||||
>Create Stall</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@click="resetDialog('stallDialog')"
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- ONBOARDING DIALOG -->
|
||||
<q-dialog v-model="onboarding.show">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">How to use Market</h6>
|
||||
<q-stepper v-model="step" color="primary" vertical animated>
|
||||
<q-step
|
||||
:name="1"
|
||||
title="Create a Shipping Zone"
|
||||
icon="settings"
|
||||
:done="step > 1"
|
||||
>
|
||||
Create Shipping Zones you're willing to ship to. You can define
|
||||
different values for different zones.
|
||||
<q-stepper-navigation>
|
||||
<q-btn @click="step = step + 1" color="primary" label="Next" />
|
||||
</q-stepper-navigation>
|
||||
</q-step>
|
||||
<q-step
|
||||
:name="2"
|
||||
title="Create a Stall"
|
||||
icon="create_new_folder"
|
||||
:done="step > 2"
|
||||
>
|
||||
Create a Stall and provide private and public keys to use for
|
||||
communication. If you don't have one, LNbits will create a key pair for
|
||||
you. It will be saved and can be used on other stalls.
|
||||
<q-stepper-navigation>
|
||||
<q-btn @click="step = step + 1" color="primary" label="Next" />
|
||||
</q-stepper-navigation>
|
||||
</q-step>
|
||||
|
||||
<q-step :name="3" title="Create Products" icon="assignment">
|
||||
Create your products, add a small description and an image. Choose to
|
||||
what stall, if you have more than one, it belongs to
|
||||
<q-stepper-navigation>
|
||||
<q-btn @click="onboarding.finish" color="primary" label="Finish" />
|
||||
</q-stepper-navigation>
|
||||
<div>
|
||||
<q-checkbox v-model="onboarding.showAgain" label="Show this again?" />
|
||||
</div>
|
||||
</q-step>
|
||||
</q-stepper>
|
||||
</q-card>
|
||||
</q-dialog>
|
440
lnbits/extensions/market/templates/market/_tables.html
Normal file
440
lnbits/extensions/market/templates/market/_tables.html
Normal file
|
@ -0,0 +1,440 @@
|
|||
<q-card>
|
||||
<!-- ORDERS TABLE -->
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Orders</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportOrdersCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="orders"
|
||||
row-key="id"
|
||||
:columns="ordersTable.columns"
|
||||
:pagination.sync="ordersTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="accent"
|
||||
round
|
||||
dense
|
||||
@click="props.expand = !props.expand"
|
||||
:icon="props.expand ? 'remove' : 'add'"
|
||||
/>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="green"
|
||||
dense
|
||||
icon="chat"
|
||||
@click="chatRoom(props.row.invoiceid)"
|
||||
>
|
||||
<q-badge
|
||||
v-if="props.row.unread"
|
||||
color="red"
|
||||
rounded
|
||||
floating
|
||||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="shipOrder(props.row.id)"
|
||||
icon="add_marketping_cart"
|
||||
color="green"
|
||||
>
|
||||
<q-tooltip> Product shipped? </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteOrder(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
<q-tr v-show="props.expand" :props="props">
|
||||
<q-td colspan="100%">
|
||||
<template>
|
||||
<div class="q-pa-md">
|
||||
<q-list>
|
||||
<q-item-label header>Order Details</q-item-label>
|
||||
|
||||
<q-item v-for="col in props.row.details" :key="col.id">
|
||||
<q-item-section>
|
||||
<q-item-label>Products</q-item-label>
|
||||
<q-item-label caption
|
||||
>{{ products.length && (_.findWhere(products, {id:
|
||||
col.product_id})).product }}</q-item-label
|
||||
>
|
||||
<q-item-label caption
|
||||
>Quantity: {{ col.quantity }}</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label>Shipping to</q-item-label>
|
||||
<q-item-label caption
|
||||
>{{ props.row.address }}</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label>User info</q-item-label>
|
||||
<q-item-label caption v-if="props.row.username"
|
||||
>{{ props.row.username }}</q-item-label
|
||||
>
|
||||
<q-item-label caption>{{ props.row.email }}</q-item-label>
|
||||
<q-item-label caption v-if="props.row.pubkey"
|
||||
>{{ props.row.pubkey }}</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label>Total</q-item-label>
|
||||
<q-item-label>{{ props.row.total }}</q-item-label>
|
||||
<!-- <q-icon name="star" color="yellow" /> -->
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</template>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<!-- PRODUCTS TABLE -->
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">
|
||||
Products
|
||||
<span v-if="stalls.length > 0" class="q-px-sm">
|
||||
<q-btn
|
||||
round
|
||||
color="primary"
|
||||
icon="add"
|
||||
size="sm"
|
||||
@click="productDialog.show = true"
|
||||
/>
|
||||
<q-tooltip> Add a product </q-tooltip>
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportProductsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="products"
|
||||
row-key="id"
|
||||
:columns="productsTable.columns"
|
||||
:pagination.sync="productsTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
disabled
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="add_marketping_cart"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="props.row.wallet"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
<q-tooltip> Link to pass to stall relay </q-tooltip>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td class="text-center" auto-width>
|
||||
<img
|
||||
v-if="props.row.image"
|
||||
:src="props.row.image"
|
||||
style="height: 1.5em"
|
||||
/>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openProductUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteProduct(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<!-- STALLS TABLE -->
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Market Stalls</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportStallsCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="stalls"
|
||||
row-key="id"
|
||||
:columns="stallTable.columns"
|
||||
:pagination.sync="stallTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="storefront"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'/market/stalls/' + props.row.id"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
<q-tooltip> Stall simple UI marketping cart </q-tooltip>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openStallUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteStall(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card v-if="markets.length">
|
||||
<!-- MARKETPLACES TABLE -->
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Marketplaces</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportStallsCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="markets"
|
||||
row-key="id"
|
||||
:columns="marketTable.columns"
|
||||
:pagination.sync="marketTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="storefront"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'/market/market/' + props.row.id"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
<q-tooltip> Link to pass to stall relay </q-tooltip>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.name == 'stalls' ? stallName(col.value) : col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openMarketUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteMarket(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<!-- ZONES TABLE -->
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Shipping Zones</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportZonesCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="zones"
|
||||
row-key="id"
|
||||
:columns="zonesTable.columns"
|
||||
:pagination.sync="zonesTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openZoneUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteZone(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
1419
lnbits/extensions/market/templates/market/index.html
Normal file
1419
lnbits/extensions/market/templates/market/index.html
Normal file
File diff suppressed because it is too large
Load Diff
175
lnbits/extensions/market/templates/market/market.html
Normal file
175
lnbits/extensions/market/templates/market/market.html
Normal file
|
@ -0,0 +1,175 @@
|
|||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-12 q-gutter-y-md">
|
||||
<q-toolbar class="row">
|
||||
<div class="col">
|
||||
<q-toolbar-title> Market: {{ market.name }} </q-toolbar-title>
|
||||
</div>
|
||||
<div class="col q-mx-md">
|
||||
<q-input
|
||||
class="float-left full-width q-ml-md"
|
||||
standout
|
||||
square
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
v-model.trim="searchText"
|
||||
label="Search for products"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon v-if="!searchText" name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</q-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div
|
||||
class="col-xs-12 col-sm-6 col-md-4 col-lg-3"
|
||||
v-for="item in filterProducts"
|
||||
:key="item.id"
|
||||
>
|
||||
<q-card class="card--product">
|
||||
{% raw %}
|
||||
<q-img
|
||||
:src="item.image ? item.image : '/market/static/images/placeholder.png'"
|
||||
alt="Product Image"
|
||||
loading="lazy"
|
||||
spinner-color="white"
|
||||
fit="contain"
|
||||
height="300px"
|
||||
></q-img>
|
||||
|
||||
<q-card-section class="q-pb-xs q-pt-md">
|
||||
<div class="row no-wrap items-center">
|
||||
<div class="col text-subtitle2 ellipsis-2-lines">
|
||||
{{ item.product }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <q-rating v-model="stars" color="orange" :max="5" readonly size="17px"></q-rating> -->
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-py-sm">
|
||||
<div>
|
||||
<div class="text-caption text-weight-bolder">
|
||||
{{ item.stallName }}
|
||||
</div>
|
||||
<span v-if="item.currency == 'sat'">
|
||||
<span class="text-h6">{{ item.price }} sats</span
|
||||
><span class="q-ml-sm text-grey-6"
|
||||
>BTC {{ (item.price / 1e8).toFixed(8) }}</span
|
||||
>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="text-h6"
|
||||
>{{ getAmountFormated(item.price, item.currency) }}</span
|
||||
>
|
||||
<span v-if="exchangeRates" class="q-ml-sm text-grey-6"
|
||||
>({{ getValueInSats(item.price, item.currency) }} sats)</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="q-ml-md text-caption text-green-8 text-weight-bolder q-mt-md"
|
||||
>{{item.quantity}} left</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="item.categories" class="text-subtitle1">
|
||||
<q-chip v-for="(cat, i) in item.categories.split(',')" :key="i" dense
|
||||
>{{cat}}</q-chip
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="text-caption text-grey ellipsis-2-lines"
|
||||
style="min-height: 40px"
|
||||
>
|
||||
<p v-if="item.description">{{ item.description }}</p>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-card-actions>
|
||||
<span>Stall: {{ item.stallName }}</span>
|
||||
<q-btn
|
||||
flat
|
||||
class="text-weight-bold text-capitalize q-ml-auto"
|
||||
dense
|
||||
color="primary"
|
||||
type="a"
|
||||
:href="'/market/stalls/' + item.stall"
|
||||
target="_blank"
|
||||
>
|
||||
Visit Stall
|
||||
</q-btn>
|
||||
</q-card-actions>
|
||||
{% endraw %}
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
stalls: null,
|
||||
products: [],
|
||||
searchText: null,
|
||||
exchangeRates: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filterProducts() {
|
||||
if (!this.searchText || this.searchText.length < 2) return this.products
|
||||
return this.products.filter(p => {
|
||||
return (
|
||||
p.product.includes(this.searchText) ||
|
||||
p.description.includes(this.searchText) ||
|
||||
p.categories.includes(this.searchText)
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async getRates() {
|
||||
let noFiat = this.stalls.map(s => s.currency).every(c => c == 'sat')
|
||||
if (noFiat) return
|
||||
try {
|
||||
let rates = await axios.get('https://api.opennode.co/v1/rates')
|
||||
this.exchangeRates = rates.data.data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getValueInSats(amount, unit = 'USD') {
|
||||
if (!this.exchangeRates) return 0
|
||||
return Math.ceil(
|
||||
(amount / this.exchangeRates[`BTC${unit}`][unit]) * 1e8
|
||||
)
|
||||
},
|
||||
getAmountFormated(amount, unit = 'USD') {
|
||||
return LNbits.utils.formatCurrency(amount, unit)
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
this.stalls = JSON.parse('{{ stalls | tojson }}')
|
||||
let products = JSON.parse('{{ products | tojson }}')
|
||||
|
||||
this.products = products.map(obj => {
|
||||
let stall = this.stalls.find(s => s.id == obj.stall)
|
||||
obj.currency = stall.currency
|
||||
if (obj.currency != 'sat') {
|
||||
obj.price = parseFloat((obj.price / 100).toFixed(2))
|
||||
}
|
||||
obj.stallName = stall.name
|
||||
return obj
|
||||
})
|
||||
await this.getRates()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
564
lnbits/extensions/market/templates/market/order.html
Normal file
564
lnbits/extensions/market/templates/market/order.html
Normal file
|
@ -0,0 +1,564 @@
|
|||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-col-gutter-md flex">
|
||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<div class="chat-container q-pa-md">
|
||||
<div class="chat-box">
|
||||
<!-- <p v-if="Object.keys(messages).length === 0">No messages yet</p> -->
|
||||
<div class="chat-messages">
|
||||
<q-chat-message
|
||||
:key="index"
|
||||
v-for="(message, index) in messages"
|
||||
:name="message.pubkey == user.keys.publickey ? 'me' : 'merchant'"
|
||||
:text="[message.msg]"
|
||||
:sent="message.pubkey == user.keys.publickey ? true : false"
|
||||
:bg-color="message.pubkey == user.keys.publickey ? 'white' : 'light-green-2'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<q-form @submit="sendMessage" class="full-width chat-input">
|
||||
<q-input
|
||||
ref="newMessage"
|
||||
v-model="newMessage"
|
||||
placeholder="Message"
|
||||
class="full-width"
|
||||
dense
|
||||
outlined
|
||||
@click="checkWebSocket"
|
||||
>
|
||||
<template>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
type="submit"
|
||||
icon="send"
|
||||
color="primary"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
</q-form>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-md-5 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
{% raw %}
|
||||
<h6 class="text-subtitle1 q-my-none">{{ stall.name }}</h6>
|
||||
<p @click="copyText(stall.publickey)" style="width: max-content">
|
||||
Public Key: {{ sliceKey(stall.publickey) }}
|
||||
<q-tooltip>Click to copy</q-tooltip>
|
||||
</p>
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
<q-card-section v-if="user">
|
||||
<q-form @submit="" class="q-gutter-md">
|
||||
<!-- <q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="model"
|
||||
:options="mockMerch"
|
||||
label="Merchant"
|
||||
hint="Select a merchant you've opened an order to"
|
||||
></q-select>
|
||||
<br /> -->
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="selectedOrder"
|
||||
:options="Object.keys(user.orders).map(o => ({label: `${o.slice(0, 25)}...`, value: o}))"
|
||||
label="Order"
|
||||
hint="Select an order from this merchant"
|
||||
@input="val => { changeOrder() }"
|
||||
emit-value
|
||||
></q-select>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-list>
|
||||
{% raw %}
|
||||
<q-item clickable :key="p.id" v-for="p in products">
|
||||
<q-item-section side>
|
||||
<span>{{p.quantity}} x </span>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-avatar color="primary">
|
||||
<img size="sm" :src="p.image" />
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ p.name }}</q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<span v-if="stall.currency != 'sat'"
|
||||
>{{ getAmountFormated(p.price) }}</span
|
||||
>
|
||||
<span v-else> {{p.price}} sats</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
{% endraw %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
<q-expansion-item group="extras" icon="vpn_key" label="Keys"
|
||||
><p>
|
||||
Bellow are the keys needed to contact the merchant. They are
|
||||
stored in the browser!
|
||||
</p>
|
||||
<div v-if="user?.keys" class="row q-col-gutter-md">
|
||||
<div
|
||||
class="col-12 col-sm-6"
|
||||
v-for="type in ['publickey', 'privatekey']"
|
||||
v-bind:key="type"
|
||||
>
|
||||
<div class="text-center q-mb-lg">
|
||||
{% raw %}
|
||||
<q-responsive
|
||||
:ratio="1"
|
||||
class="q-mx-auto"
|
||||
style="max-width: 250px"
|
||||
>
|
||||
<qrcode
|
||||
:value="user.keys[type]"
|
||||
:options="{width: 500}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
<q-tooltip>{{ user.keys[type] }}</q-tooltip>
|
||||
</q-responsive>
|
||||
<p>
|
||||
{{ type == 'publickey' ? 'Public Key' : 'Private Key' }}
|
||||
</p>
|
||||
{% endraw %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="downloadKeys"
|
||||
>Backup keys
|
||||
<q-tooltip>Download your keys</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
class="q-mx-sm"
|
||||
@click="keysDialog.show = true"
|
||||
:disabled="this.user.keys"
|
||||
>Restore keys
|
||||
<q-tooltip>Restore keys</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
@click="deleteData"
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Delete data
|
||||
<q-tooltip>Delete all data from browser</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
<q-expansion-item icon="qr_code" label="Export page">
|
||||
<p>Export, or send, this page to another device</p>
|
||||
<div class="text-center q-mb-lg">
|
||||
<q-responsive
|
||||
:ratio="1"
|
||||
class="q-my-xl q-mx-auto"
|
||||
style="max-width: 250px"
|
||||
@click="copyText(exportURL)"
|
||||
>
|
||||
<qrcode
|
||||
:value="exportURL"
|
||||
:options="{width: 500}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
<q-tooltip>Click to copy</q-tooltip>
|
||||
</q-responsive>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@click="copyText(exportURL)"
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Copy URL
|
||||
<q-tooltip
|
||||
>Export, or send, this page to another device</q-tooltip
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<!-- RESTORE KEYS DIALOG -->
|
||||
<q-dialog
|
||||
v-if="diagonalley"
|
||||
v-model="keysDialog.show"
|
||||
position="top"
|
||||
@hide="clearRestoreKeyDialog"
|
||||
>
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> </q-card>
|
||||
<q-card class="q-pa-lg lnbits__dialog-card">
|
||||
<q-form @submit="restoreKeys" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="keysDialog.data.publickey"
|
||||
label="Public Key"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="keysDialog.data.privatekey"
|
||||
label="Private Key *optional"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="keysDialog.data.publickey == null"
|
||||
type="submit"
|
||||
label="Submit"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@click="clearRestoreKeyDialog"
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
label="Cancel"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- ONBOARDING DIALOG -->
|
||||
<q-dialog v-model="lnbitsBookmark.show">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">Bookmark this page</h6>
|
||||
<p>
|
||||
Don't forget to bookmark this page to be able to check on your order!
|
||||
</p>
|
||||
<p>
|
||||
You can backup your keys, and export the page to another device also.
|
||||
</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@click="lnbitsBookmark.finish"
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Close</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
|
||||
|
||||
<script>
|
||||
const mapChatMsg = msg => {
|
||||
let obj = {}
|
||||
obj.timestamp = {
|
||||
msg: msg,
|
||||
pubkey: pubkey
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
const mapProductsItems = obj => {
|
||||
obj.price = (obj.price / 100).toFixed(2)
|
||||
|
||||
return obj
|
||||
}
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
const nostr = window.NostrTools
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
lnbitsBookmark: {
|
||||
show: true,
|
||||
finish: () => {
|
||||
this.$q.localStorage.set('lnbits.marketbookmark', false)
|
||||
this.lnbitsBookmark.show = false
|
||||
}
|
||||
},
|
||||
newMessage: '',
|
||||
showMessages: false,
|
||||
messages: {},
|
||||
stall: null,
|
||||
selectedOrder: null,
|
||||
diagonalley: false,
|
||||
products: [],
|
||||
orders: [],
|
||||
user: {
|
||||
keys: {},
|
||||
orders: {}
|
||||
},
|
||||
keysDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
exportURL() {
|
||||
return (
|
||||
'{{request.url}}' +
|
||||
`&keys=${this.user.keys.publickey},${this.user.keys.privatekey}`
|
||||
)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getAmountFormated(amount) {
|
||||
return LNbits.utils.formatCurrency(amount, this.stall.currency)
|
||||
},
|
||||
clearMessage() {
|
||||
this.newMessage = ''
|
||||
this.$refs.newMessage.focus()
|
||||
},
|
||||
clearRestoreKeyDialog() {
|
||||
this.keysDialog = {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
},
|
||||
sendMessage() {
|
||||
let message = {
|
||||
msg: this.newMessage,
|
||||
pubkey: this.user.keys.publickey
|
||||
}
|
||||
this.ws.send(JSON.stringify(message))
|
||||
|
||||
this.clearMessage()
|
||||
},
|
||||
sliceKey(key) {
|
||||
if (!key) return ''
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`
|
||||
},
|
||||
downloadKeys() {
|
||||
const file = new File(
|
||||
[JSON.stringify(this.user.keys)],
|
||||
'backup_keys.json',
|
||||
{
|
||||
type: 'text/json'
|
||||
}
|
||||
)
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(file)
|
||||
|
||||
link.href = url
|
||||
link.download = file.name
|
||||
link.click()
|
||||
|
||||
window.URL.revokeObjectURL(url)
|
||||
},
|
||||
restoreKeys() {
|
||||
this.user.keys = this.keysDialog.data
|
||||
let data = this.$q.localStorage.getItem(`lnbits.market.data`)
|
||||
this.$q.localStorage.set(`lnbits.market.data`, {
|
||||
...data,
|
||||
keys: this.user.keys
|
||||
})
|
||||
|
||||
this.clearRestoreKeyDialog()
|
||||
},
|
||||
deleteData() {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete your stored data?')
|
||||
.onOk(() => {
|
||||
this.$q.localStorage.remove('lnbits.market.data')
|
||||
this.user = null
|
||||
})
|
||||
},
|
||||
generateKeys() {
|
||||
//check if the keys are set
|
||||
if ('publickey' in this.user.keys && 'privatekey' in this.user.keys)
|
||||
return
|
||||
|
||||
const privkey = nostr.generatePrivateKey()
|
||||
const pubkey = nostr.getPublicKey(privkey)
|
||||
|
||||
this.user.keys = {
|
||||
privatekey: privkey,
|
||||
publickey: pubkey
|
||||
}
|
||||
},
|
||||
async getMessages(room_name, all = false) {
|
||||
await LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
`/market/api/v1/chat/messages/${room_name}${
|
||||
all ? '?all_messages=true' : ''
|
||||
}`
|
||||
)
|
||||
.then(response => {
|
||||
if (response.data) {
|
||||
response.data.reverse().map(m => {
|
||||
this.$set(this.messages, m.timestamp * 1000, {
|
||||
msg: m.msg,
|
||||
pubkey: m.pubkey
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
async changeOrder() {
|
||||
this.products = this.user.orders[this.selectedOrder]
|
||||
this.messages = {}
|
||||
await this.getMessages(this.selectedOrder)
|
||||
this.startChat(this.selectedOrder)
|
||||
},
|
||||
checkWebSocket() {
|
||||
if (!this.ws) return
|
||||
if (this.ws.readyState === WebSocket.CLOSED) {
|
||||
console.log('WebSocket CLOSED: Reopening')
|
||||
this.ws = new WebSocket(
|
||||
ws_scheme + location.host + '/market/ws/' + this.selectedOrder
|
||||
)
|
||||
}
|
||||
},
|
||||
startChat(room_name) {
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
}
|
||||
if (location.protocol == 'https:') {
|
||||
ws_scheme = 'wss://'
|
||||
} else {
|
||||
ws_scheme = 'ws://'
|
||||
}
|
||||
ws = new WebSocket(
|
||||
ws_scheme + location.host + '/market/ws/' + room_name
|
||||
)
|
||||
|
||||
ws.onmessage = event => {
|
||||
let event_data = JSON.parse(event.data)
|
||||
|
||||
this.$set(this.messages, Date.now(), event_data)
|
||||
}
|
||||
|
||||
this.ws = ws
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
let showBookmark = this.$q.localStorage.getItem('lnbits.marketbookmark')
|
||||
this.lnbitsBookmark.show = showBookmark === true || showBookmark == null
|
||||
|
||||
let order_details = JSON.parse('{{ order | tojson }}')
|
||||
let products = JSON.parse('{{ products | tojson }}')
|
||||
let order_id = '{{ order_id }}'
|
||||
let hasKeys = Boolean(
|
||||
JSON.parse('{{ publickey | tojson }}') &&
|
||||
JSON.parse('{{ privatekey | tojson }}')
|
||||
)
|
||||
|
||||
if (hasKeys) {
|
||||
this.user.keys = {
|
||||
privatekey: '{{ privatekey }}',
|
||||
publickey: '{{ publickey }}'
|
||||
}
|
||||
}
|
||||
|
||||
this.stall = JSON.parse('{{ stall | tojson }}')
|
||||
this.products = order_details.map(o => {
|
||||
let product = products.find(p => p.id == o.product_id)
|
||||
return {
|
||||
quantity: o.quantity,
|
||||
name: product.product,
|
||||
image: product.image,
|
||||
price: product.price
|
||||
}
|
||||
})
|
||||
console.log(this.stall)
|
||||
if (this.stall.currency != 'sat') {
|
||||
this.products = this.products.map(mapProductsItems)
|
||||
}
|
||||
|
||||
let data = this.$q.localStorage.getItem(`lnbits.market.data`) || false
|
||||
|
||||
if (data) {
|
||||
this.user = data
|
||||
if (!this.user.orders[`${order_id}`]) {
|
||||
this.$set(this.user.orders, order_id, this.products)
|
||||
}
|
||||
} else {
|
||||
// generate keys
|
||||
this.generateKeys()
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'GET',
|
||||
`/market/api/v1/order/pubkey/${order_id}/${this.user.keys.publickey}`
|
||||
)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
// populate user data
|
||||
this.user.orders = {
|
||||
[`${order_id}`]: this.products
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedOrder = order_id
|
||||
|
||||
await this.getMessages(order_id)
|
||||
|
||||
this.$q.localStorage.set(`lnbits.market.data`, this.user)
|
||||
this.startChat(order_id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.q-field__native span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
/*height: calc(100vh - 200px);*/
|
||||
height: 70vh;
|
||||
}
|
||||
|
||||
.chat-box {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
margin-left: auto;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.chat-other {
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
14
lnbits/extensions/market/templates/market/product.html
Normal file
14
lnbits/extensions/market/templates/market/product.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
{% extends "public.html" %} {% block page %}
|
||||
<h1>Product page</h1>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
531
lnbits/extensions/market/templates/market/stall.html
Normal file
531
lnbits/extensions/market/templates/market/stall.html
Normal file
|
@ -0,0 +1,531 @@
|
|||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-12 q-gutter-y-md">
|
||||
<q-toolbar class="row">
|
||||
<div class="col">
|
||||
<q-toolbar-title> Stall: {{ stall.name }} </q-toolbar-title>
|
||||
</div>
|
||||
<div class="col q-mx-md">
|
||||
<q-input
|
||||
class="float-left full-width q-ml-md"
|
||||
standout
|
||||
square
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
v-model.trim="searchText"
|
||||
label="Search for products"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon v-if="!searchText" name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
<q-btn dense round flat icon="shopping_cart">
|
||||
{% raw %}
|
||||
<q-badge v-if="cart.size" color="red" class="text-bold" floating>
|
||||
{{ cart.size }}
|
||||
</q-badge>
|
||||
{% endraw %}
|
||||
<q-menu v-if="cart.size">
|
||||
<q-list style="min-width: 100px">
|
||||
{% raw %}
|
||||
<q-item :key="p.id" v-for="p in cartMenu">
|
||||
<q-item-section side>
|
||||
<span>{{p.quantity}} x </span>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-avatar color="primary">
|
||||
<img
|
||||
size="sm"
|
||||
:src="products.find(f => f.id == p.id).image"
|
||||
/>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label>{{ p.name }}</q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<span>
|
||||
{{unit != 'sat' ? getAmountFormated(p.price) : p.price +
|
||||
'sats'}}
|
||||
<q-btn
|
||||
class="q-ml-md"
|
||||
round
|
||||
color="red"
|
||||
size="xs"
|
||||
icon="close"
|
||||
@click="removeFromCart(p)"
|
||||
/>
|
||||
</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
{% endraw %}
|
||||
<q-separator />
|
||||
</q-list>
|
||||
<div class="row q-pa-md q-gutter-md">
|
||||
<q-btn
|
||||
color="primary"
|
||||
icon-right="checkout"
|
||||
label="Checkout"
|
||||
@click="checkoutDialog.show = true"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
class="q-ml-lg"
|
||||
flat
|
||||
color="primary"
|
||||
label="Reset"
|
||||
@click="resetCart"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</q-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div
|
||||
class="col-xs-12 col-sm-6 col-md-4 col-lg-3"
|
||||
v-for="item in filterProducts"
|
||||
:key="item.id"
|
||||
>
|
||||
<q-card class="card--product">
|
||||
{% raw %}
|
||||
<q-img
|
||||
:src="item.image ? item.image : '/market/static/images/placeholder.png'"
|
||||
alt="Product Image"
|
||||
loading="lazy"
|
||||
spinner-color="white"
|
||||
fit="contain"
|
||||
height="300px"
|
||||
></q-img>
|
||||
|
||||
<q-card-section class="q-pb-xs q-pt-md">
|
||||
<q-btn
|
||||
round
|
||||
:disabled="item.quantity < 1"
|
||||
color="primary"
|
||||
icon="shopping_cart"
|
||||
size="lg"
|
||||
style="
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
"
|
||||
@click="addToCart(item)"
|
||||
><q-tooltip> Add to cart </q-tooltip></q-btn
|
||||
>
|
||||
|
||||
<div class="row no-wrap items-center">
|
||||
<div class="col text-subtitle2 ellipsis-2-lines">
|
||||
{{ item.product }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <q-rating v-model="stars" color="orange" :max="5" readonly size="17px"></q-rating> -->
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-py-sm">
|
||||
<div>
|
||||
<span v-if="unit == 'sat'">
|
||||
<span class="text-h6">{{ item.price }} sats</span
|
||||
><span class="q-ml-sm text-grey-6"
|
||||
>BTC {{ (item.price / 1e8).toFixed(8) }}</span
|
||||
>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="text-h6">{{ getAmountFormated(item.price) }}</span>
|
||||
<span v-if="exchangeRate" class="q-ml-sm text-grey-6"
|
||||
>({{ getValueInSats(item.price) }} sats)</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="q-ml-md text-caption text-green-8 text-weight-bolder q-mt-md"
|
||||
>{{item.quantity}} left</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="item.categories" class="text-subtitle1">
|
||||
<q-chip v-for="(cat, i) in item.categories.split(',')" :key="i" dense
|
||||
>{{cat}}</q-chip
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="text-caption text-grey ellipsis-2-lines"
|
||||
style="min-height: 40px"
|
||||
>
|
||||
<p v-if="item.description">{{ item.description }}</p>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<!-- <q-separator></q-separator>
|
||||
|
||||
<q-card-actions>
|
||||
<q-btn
|
||||
flat
|
||||
class="text-weight-bold text-capitalize"
|
||||
dense
|
||||
color="primary"
|
||||
>
|
||||
View details
|
||||
</q-btn>
|
||||
</q-card-actions> -->
|
||||
{% endraw %}
|
||||
</q-card>
|
||||
</div>
|
||||
<!-- CHECKOUT DIALOG -->
|
||||
<q-dialog v-model="checkoutDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="placeOrder" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="checkoutDialog.data.username"
|
||||
label="Name *optional"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-if="diagonalley"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="checkoutDialog.data.pubkey"
|
||||
label="Public key *optional"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon @click="getPubkey" name="settings_backup_restore" />
|
||||
<q-tooltip>Click to restore saved public key</q-tooltip>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="checkoutDialog.data.address"
|
||||
label="Address"
|
||||
></q-input>
|
||||
<!-- <q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="checkoutDialog.data.address_2"
|
||||
label="Address (line 2)"
|
||||
></q-input> -->
|
||||
<q-input
|
||||
v-model="checkoutDialog.data.email"
|
||||
filled
|
||||
dense
|
||||
type="email"
|
||||
label="Email"
|
||||
></q-input>
|
||||
<p>Select the shipping zone:</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-option-group
|
||||
:options="stall.zones"
|
||||
type="radio"
|
||||
emit-value
|
||||
v-model="checkoutDialog.data.shippingzone"
|
||||
/>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
{% raw %} Total: {{ unit != 'sat' ? getAmountFormated(finalCost) :
|
||||
finalCost + 'sats' }}
|
||||
<span v-if="unit != 'sat'" class="q-ml-sm text-grey-6"
|
||||
>({{ getValueInSats(finalCost) }} sats)</span
|
||||
>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="checkoutDialog.data.address == null
|
||||
|| checkoutDialog.data.email == null
|
||||
|| checkoutDialog.data.shippingzone == null"
|
||||
type="submit"
|
||||
>Checkout</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
@click="checkoutDialog = {show: false, data: {pubkey: ''}}"
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- INVOICE DIALOG -->
|
||||
<q-dialog
|
||||
v-model="qrCodeDialog.show"
|
||||
position="top"
|
||||
@hide="closeQrCodeDialog"
|
||||
>
|
||||
<q-card
|
||||
v-if="!qrCodeDialog.data.payment_request"
|
||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||
>
|
||||
</q-card>
|
||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + qrCodeDialog.data.payment_request">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data.payment_request"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(qrCodeDialog.data.payment_request)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
<q-btn
|
||||
@click="closeQrCodeDialog"
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Close</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
const mapProductsItems = obj => {
|
||||
obj.price = parseFloat((obj.price / 100).toFixed(2))
|
||||
|
||||
return obj
|
||||
}
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
stall: null,
|
||||
products: [],
|
||||
searchText: null,
|
||||
diagonalley: false,
|
||||
unit: 'sat',
|
||||
exchangeRate: 0,
|
||||
cart: {
|
||||
total: 0,
|
||||
size: 0,
|
||||
products: new Map()
|
||||
},
|
||||
cartMenu: [],
|
||||
checkoutDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
pubkey: ''
|
||||
}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
data: {
|
||||
payment_request: null
|
||||
},
|
||||
show: false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filterProducts() {
|
||||
if (!this.searchText || this.searchText.length < 2) return this.products
|
||||
return this.products.filter(p => {
|
||||
return (
|
||||
p.product.includes(this.searchText) ||
|
||||
p.description.includes(this.searchText) ||
|
||||
p.categories.includes(this.searchText)
|
||||
)
|
||||
})
|
||||
},
|
||||
finalCost() {
|
||||
if (!this.checkoutDialog.data.shippingzone) return this.cart.total
|
||||
|
||||
let zoneCost = this.stall.zones.find(
|
||||
z => z.value == this.checkoutDialog.data.shippingzone
|
||||
)
|
||||
return +this.cart.total + zoneCost.cost
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeQrCodeDialog() {
|
||||
this.qrCodeDialog.dismissMsg()
|
||||
this.qrCodeDialog.show = false
|
||||
},
|
||||
resetCart() {
|
||||
this.cart = {
|
||||
total: 0,
|
||||
size: 0,
|
||||
products: new Map()
|
||||
}
|
||||
},
|
||||
getAmountFormated(amount) {
|
||||
return LNbits.utils.formatCurrency(amount, this.unit)
|
||||
},
|
||||
async getRates() {
|
||||
if (this.unit == 'sat') return
|
||||
try {
|
||||
let rate = (
|
||||
await LNbits.api.request('POST', '/api/v1/conversion', null, {
|
||||
amount: 1e8,
|
||||
to: this.unit
|
||||
})
|
||||
).data
|
||||
this.exchangeRate = rate[this.unit]
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getValueInSats(amount) {
|
||||
if (!this.exchangeRate) return 0
|
||||
return Math.ceil((amount / this.exchangeRate) * 1e8)
|
||||
},
|
||||
addToCart(item) {
|
||||
let prod = this.cart.products
|
||||
if (prod.has(item.id)) {
|
||||
let qty = prod.get(item.id).quantity
|
||||
prod.set(item.id, {
|
||||
...prod.get(item.id),
|
||||
quantity: qty + 1
|
||||
})
|
||||
} else {
|
||||
prod.set(item.id, {
|
||||
name: item.product,
|
||||
quantity: 1,
|
||||
price: item.price
|
||||
})
|
||||
}
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: `${item.product} added to cart`,
|
||||
icon: 'thumb_up'
|
||||
})
|
||||
this.cart.products = prod
|
||||
this.updateCart(+item.price)
|
||||
},
|
||||
removeFromCart(item) {
|
||||
this.cart.products.delete(item.id)
|
||||
this.updateCart(+item.price, true)
|
||||
},
|
||||
updateCart(price, del = false) {
|
||||
console.log(this.cart, this.cartMenu)
|
||||
if (del) {
|
||||
this.cart.total -= price
|
||||
this.cart.size--
|
||||
} else {
|
||||
this.cart.total += price
|
||||
this.cart.size++
|
||||
}
|
||||
this.cartMenu = Array.from(this.cart.products, item => {
|
||||
return {id: item[0], ...item[1]}
|
||||
})
|
||||
console.log(this.cart, this.cartMenu)
|
||||
},
|
||||
getPubkey() {
|
||||
let data = this.$q.localStorage.getItem(`lnbits.market.data`)
|
||||
if (data && data.keys.publickey) {
|
||||
this.checkoutDialog.data.pubkey = data.keys.publickey
|
||||
} else {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'No public key stored!',
|
||||
icon: 'settings_backup_restore'
|
||||
})
|
||||
}
|
||||
},
|
||||
placeOrder() {
|
||||
let dialog = this.checkoutDialog.data
|
||||
let data = {
|
||||
...this.checkoutDialog.data,
|
||||
wallet: this.stall.wallet,
|
||||
total:
|
||||
this.unit != 'sat'
|
||||
? this.getValueInSats(this.finalCost)
|
||||
: this.finalCost, // maybe this is better made in Python to allow API ordering?!
|
||||
products: Array.from(this.cart.products, p => {
|
||||
return {product_id: p[0], quantity: p[1].quantity}
|
||||
})
|
||||
}
|
||||
LNbits.api
|
||||
.request('POST', '/market/api/v1/orders', null, data)
|
||||
.then(res => {
|
||||
this.checkoutDialog = {show: false, data: {}}
|
||||
|
||||
return res.data
|
||||
})
|
||||
.then(data => {
|
||||
this.qrCodeDialog.data = data
|
||||
this.qrCodeDialog.show = true
|
||||
|
||||
this.qrCodeDialog.dismissMsg = this.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Waiting for payment...'
|
||||
})
|
||||
return data
|
||||
})
|
||||
.then(data => {
|
||||
this.qrCodeDialog.paymentChecker = setInterval(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
`/market/api/v1/orders/payments/${this.qrCodeDialog.data.payment_hash}`
|
||||
)
|
||||
.then(res => {
|
||||
if (res.data.paid) {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
multiLine: true,
|
||||
message:
|
||||
"Sats received, thanks! You'l be redirected to the order page...",
|
||||
icon: 'thumb_up',
|
||||
actions: [
|
||||
{
|
||||
label: 'See Order',
|
||||
handler: () => {
|
||||
window.location.href = `/market/order/?merch=${this.stall.id}&invoice_id=${this.qrCodeDialog.data.payment_hash}`
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
clearInterval(this.qrCodeDialog.paymentChecker)
|
||||
this.resetCart()
|
||||
this.closeQrCodeDialog()
|
||||
setTimeout(() => {
|
||||
window.location.href = `/market/order/?merch=${this.stall.id}&invoice_id=${this.qrCodeDialog.data.payment_hash}`
|
||||
}, 5000)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}, 3000)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
this.stall = JSON.parse('{{ stall | tojson }}')
|
||||
this.products = JSON.parse('{{ products | tojson }}')
|
||||
this.unit = this.stall.currency
|
||||
if (this.unit != 'sat') {
|
||||
this.products = this.products.map(mapProductsItems)
|
||||
}
|
||||
await this.getRates()
|
||||
setInterval(this.getRates, 300000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
177
lnbits/extensions/market/views.py
Normal file
177
lnbits/extensions/market/views.py
Normal file
|
@ -0,0 +1,177 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
from typing import List
|
||||
|
||||
from fastapi import (
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
Query,
|
||||
Request,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists # type: ignore
|
||||
from lnbits.extensions.market import market_ext, market_renderer
|
||||
from lnbits.extensions.market.models import CreateChatMessage, SetSettings
|
||||
from lnbits.extensions.market.notifier import Notifier
|
||||
|
||||
from .crud import (
|
||||
create_chat_message,
|
||||
create_market_settings,
|
||||
get_market_market,
|
||||
get_market_market_stalls,
|
||||
get_market_order_details,
|
||||
get_market_order_invoiceid,
|
||||
get_market_products,
|
||||
get_market_settings,
|
||||
get_market_stall,
|
||||
get_market_zone,
|
||||
get_market_zones,
|
||||
update_market_product_stock,
|
||||
)
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@market_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
settings = await get_market_settings(user=user.id)
|
||||
|
||||
if not settings:
|
||||
await create_market_settings(
|
||||
user=user.id, data=SetSettings(currency="sat", fiat_base_multiplier=1)
|
||||
)
|
||||
settings = await get_market_settings(user.id)
|
||||
assert settings
|
||||
return market_renderer().TemplateResponse(
|
||||
"market/index.html",
|
||||
{"request": request, "user": user.dict(), "currency": settings.currency},
|
||||
)
|
||||
|
||||
|
||||
@market_ext.get("/stalls/{stall_id}", response_class=HTMLResponse)
|
||||
async def stall(request: Request, stall_id):
|
||||
stall = await get_market_stall(stall_id)
|
||||
products = await get_market_products(stall_id)
|
||||
|
||||
if not stall:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Stall does not exist."
|
||||
)
|
||||
|
||||
zones = []
|
||||
for id in stall.shippingzones.split(","):
|
||||
zone = await get_market_zone(id)
|
||||
assert zone
|
||||
z = zone.dict()
|
||||
zones.append({"label": z["countries"], "cost": z["cost"], "value": z["id"]})
|
||||
|
||||
_stall = stall.dict()
|
||||
|
||||
_stall["zones"] = zones
|
||||
|
||||
return market_renderer().TemplateResponse(
|
||||
"market/stall.html",
|
||||
{
|
||||
"request": request,
|
||||
"stall": _stall,
|
||||
"products": [product.dict() for product in products],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@market_ext.get("/market/{market_id}", response_class=HTMLResponse)
|
||||
async def market(request: Request, market_id):
|
||||
market = await get_market_market(market_id)
|
||||
|
||||
if not market:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Marketplace doesn't exist."
|
||||
)
|
||||
|
||||
stalls = await get_market_market_stalls(market_id)
|
||||
stalls_ids = [stall.id for stall in stalls]
|
||||
products = [product.dict() for product in await get_market_products(stalls_ids)]
|
||||
|
||||
return market_renderer().TemplateResponse(
|
||||
"market/market.html",
|
||||
{
|
||||
"request": request,
|
||||
"market": market,
|
||||
"stalls": [stall.dict() for stall in stalls],
|
||||
"products": products,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@market_ext.get("/order", response_class=HTMLResponse)
|
||||
async def order_chat(
|
||||
request: Request,
|
||||
merch: str = Query(...),
|
||||
invoice_id: str = Query(...),
|
||||
keys: str = Query(None),
|
||||
):
|
||||
stall = await get_market_stall(merch)
|
||||
assert stall
|
||||
order = await get_market_order_invoiceid(invoice_id)
|
||||
assert order
|
||||
_order = await get_market_order_details(order.id)
|
||||
products = await get_market_products(stall.id)
|
||||
assert products
|
||||
|
||||
return market_renderer().TemplateResponse(
|
||||
"market/order.html",
|
||||
{
|
||||
"request": request,
|
||||
"stall": {
|
||||
"id": stall.id,
|
||||
"name": stall.name,
|
||||
"publickey": stall.publickey,
|
||||
"wallet": stall.wallet,
|
||||
"currency": stall.currency,
|
||||
},
|
||||
"publickey": keys.split(",")[0] if keys else None,
|
||||
"privatekey": keys.split(",")[1] if keys else None,
|
||||
"order_id": order.invoiceid,
|
||||
"order": [details.dict() for details in _order],
|
||||
"products": [product.dict() for product in products],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
##################WEBSOCKET ROUTES########################
|
||||
|
||||
# Initialize Notifier:
|
||||
notifier = Notifier()
|
||||
|
||||
|
||||
@market_ext.websocket("/ws/{room_name}")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket, room_name: str, background_tasks: BackgroundTasks
|
||||
):
|
||||
await notifier.connect(websocket, room_name)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
d = json.loads(data)
|
||||
d["room_name"] = room_name
|
||||
|
||||
room_members = (
|
||||
notifier.get_members(room_name)
|
||||
if notifier.get_members(room_name) is not None
|
||||
else []
|
||||
)
|
||||
|
||||
if websocket not in room_members:
|
||||
print("Sender not in room member: Reconnecting...")
|
||||
await notifier.connect(websocket, room_name)
|
||||
await notifier._notify(data, room_name)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
notifier.remove(websocket, room_name)
|
518
lnbits/extensions/market/views_api.py
Normal file
518
lnbits/extensions/market/views_api.py
Normal file
|
@ -0,0 +1,518 @@
|
|||
from base64 import urlsafe_b64encode
|
||||
from http import HTTPStatus
|
||||
from typing import List, Union
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Body, Depends, Query, Request
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
|
||||
|
||||
from . import db, market_ext
|
||||
from .crud import (
|
||||
create_market_market,
|
||||
create_market_market_stalls,
|
||||
create_market_order,
|
||||
create_market_order_details,
|
||||
create_market_product,
|
||||
create_market_settings,
|
||||
create_market_stall,
|
||||
create_market_zone,
|
||||
delete_market_order,
|
||||
delete_market_product,
|
||||
delete_market_stall,
|
||||
delete_market_zone,
|
||||
get_market_chat_by_merchant,
|
||||
get_market_chat_messages,
|
||||
get_market_latest_chat_messages,
|
||||
get_market_market,
|
||||
get_market_market_stalls,
|
||||
get_market_markets,
|
||||
get_market_order,
|
||||
get_market_order_details,
|
||||
get_market_order_invoiceid,
|
||||
get_market_orders,
|
||||
get_market_product,
|
||||
get_market_products,
|
||||
get_market_settings,
|
||||
get_market_stall,
|
||||
get_market_stalls,
|
||||
get_market_stalls_by_ids,
|
||||
get_market_zone,
|
||||
get_market_zones,
|
||||
set_market_order_pubkey,
|
||||
set_market_settings,
|
||||
update_market_market,
|
||||
update_market_product,
|
||||
update_market_stall,
|
||||
update_market_zone,
|
||||
)
|
||||
from .models import (
|
||||
CreateMarket,
|
||||
CreateMarketStalls,
|
||||
Orders,
|
||||
Products,
|
||||
SetSettings,
|
||||
Stalls,
|
||||
Zones,
|
||||
createOrder,
|
||||
createProduct,
|
||||
createStalls,
|
||||
createZones,
|
||||
)
|
||||
|
||||
# from lnbits.db import open_ext_db
|
||||
|
||||
|
||||
### Products
|
||||
@market_ext.get("/api/v1/products")
|
||||
async def api_market_products(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
all_stalls: bool = Query(False),
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
|
||||
if all_stalls:
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
stalls = [stall.id for stall in await get_market_stalls(wallet_ids)]
|
||||
|
||||
if not stalls:
|
||||
return
|
||||
|
||||
return [product.dict() for product in await get_market_products(stalls)]
|
||||
|
||||
|
||||
@market_ext.post("/api/v1/products")
|
||||
@market_ext.put("/api/v1/products/{product_id}")
|
||||
async def api_market_product_create(
|
||||
data: createProduct,
|
||||
product_id=None,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
):
|
||||
# For fiat currencies,
|
||||
# we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
|
||||
settings = await get_market_settings(user=wallet.wallet.user)
|
||||
assert settings
|
||||
|
||||
stall = await get_market_stall(stall_id=data.stall)
|
||||
assert stall
|
||||
|
||||
if stall.currency != "sat":
|
||||
data.price *= settings.fiat_base_multiplier
|
||||
|
||||
if product_id:
|
||||
product = await get_market_product(product_id)
|
||||
if not product:
|
||||
return {"message": "Product does not exist."}
|
||||
|
||||
# stall = await get_market_stall(stall_id=product.stall)
|
||||
if stall.wallet != wallet.wallet.id:
|
||||
return {"message": "Not your product."}
|
||||
|
||||
product = await update_market_product(product_id, **data.dict())
|
||||
else:
|
||||
product = await create_market_product(data=data)
|
||||
assert product
|
||||
return product.dict()
|
||||
|
||||
|
||||
@market_ext.delete("/api/v1/products/{product_id}")
|
||||
async def api_market_products_delete(
|
||||
product_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
product = await get_market_product(product_id)
|
||||
|
||||
if not product:
|
||||
return {"message": "Product does not exist."}
|
||||
|
||||
stall = await get_market_stall(product.stall)
|
||||
assert stall
|
||||
|
||||
if stall.wallet != wallet.wallet.id:
|
||||
return {"message": "Not your Market."}
|
||||
|
||||
await delete_market_product(product_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
# # # Shippingzones
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/zones")
|
||||
async def api_market_zones(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
|
||||
return await get_market_zones(wallet.wallet.user)
|
||||
|
||||
|
||||
@market_ext.post("/api/v1/zones")
|
||||
async def api_market_zone_create(
|
||||
data: createZones, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
zone = await create_market_zone(user=wallet.wallet.user, data=data)
|
||||
return zone.dict()
|
||||
|
||||
|
||||
@market_ext.post("/api/v1/zones/{zone_id}")
|
||||
async def api_market_zone_update(
|
||||
data: createZones,
|
||||
zone_id: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
zone = await get_market_zone(zone_id)
|
||||
if not zone:
|
||||
return {"message": "Zone does not exist."}
|
||||
if zone.user != wallet.wallet.user:
|
||||
return {"message": "Not your record."}
|
||||
zone = await update_market_zone(zone_id, **data.dict())
|
||||
return zone
|
||||
|
||||
|
||||
@market_ext.delete("/api/v1/zones/{zone_id}")
|
||||
async def api_market_zone_delete(
|
||||
zone_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
zone = await get_market_zone(zone_id)
|
||||
|
||||
if not zone:
|
||||
return {"message": "zone does not exist."}
|
||||
|
||||
if zone.user != wallet.wallet.user:
|
||||
return {"message": "Not your zone."}
|
||||
|
||||
await delete_market_zone(zone_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
# # # Stalls
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/stalls")
|
||||
async def api_market_stalls(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
return [stall.dict() for stall in await get_market_stalls(wallet_ids)]
|
||||
|
||||
|
||||
@market_ext.post("/api/v1/stalls")
|
||||
@market_ext.put("/api/v1/stalls/{stall_id}")
|
||||
async def api_market_stall_create(
|
||||
data: createStalls,
|
||||
stall_id: str = None,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
):
|
||||
|
||||
if stall_id:
|
||||
stall = await get_market_stall(stall_id)
|
||||
if not stall:
|
||||
return {"message": "Withdraw stall does not exist."}
|
||||
|
||||
if stall.wallet != wallet.wallet.id:
|
||||
return {"message": "Not your withdraw stall."}
|
||||
|
||||
stall = await update_market_stall(stall_id, **data.dict())
|
||||
else:
|
||||
stall = await create_market_stall(data=data)
|
||||
assert stall
|
||||
return stall.dict()
|
||||
|
||||
|
||||
@market_ext.delete("/api/v1/stalls/{stall_id}")
|
||||
async def api_market_stall_delete(
|
||||
stall_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
stall = await get_market_stall(stall_id)
|
||||
|
||||
if not stall:
|
||||
return {"message": "Stall does not exist."}
|
||||
|
||||
if stall.wallet != wallet.wallet.id:
|
||||
return {"message": "Not your Stall."}
|
||||
|
||||
await delete_market_stall(stall_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
###Orders
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/orders")
|
||||
async def api_market_orders(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
if all_wallets:
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
orders = await get_market_orders(wallet_ids)
|
||||
if not orders:
|
||||
return
|
||||
orders_with_details = []
|
||||
for order in orders:
|
||||
_order = order.dict()
|
||||
_order["details"] = await get_market_order_details(_order["id"])
|
||||
orders_with_details.append(_order)
|
||||
try:
|
||||
return orders_with_details # [order for order in orders]
|
||||
# return [order.dict() for order in await get_market_orders(wallet_ids)]
|
||||
except:
|
||||
return {"message": "We could not retrieve the orders."}
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/orders/{order_id}")
|
||||
async def api_market_order_by_id(order_id: str):
|
||||
order = await get_market_order(order_id)
|
||||
assert order
|
||||
_order = order.dict()
|
||||
_order["details"] = await get_market_order_details(order_id)
|
||||
|
||||
return _order
|
||||
|
||||
|
||||
@market_ext.post("/api/v1/orders")
|
||||
async def api_market_order_create(data: createOrder):
|
||||
ref = urlsafe_short_hash()
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=data.wallet,
|
||||
amount=data.total,
|
||||
memo=f"New order on Market",
|
||||
extra={
|
||||
"tag": "market",
|
||||
"reference": ref,
|
||||
},
|
||||
)
|
||||
order_id = await create_market_order(invoiceid=payment_hash, data=data)
|
||||
logger.debug(f"ORDER ID {order_id}")
|
||||
logger.debug(f"PRODUCTS {data.products}")
|
||||
await create_market_order_details(order_id=order_id, data=data.products)
|
||||
return {
|
||||
"payment_hash": payment_hash,
|
||||
"payment_request": payment_request,
|
||||
"order_reference": ref,
|
||||
}
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/orders/payments/{payment_hash}")
|
||||
async def api_market_check_payment(payment_hash: str):
|
||||
order = await get_market_order_invoiceid(payment_hash)
|
||||
if not order:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Order does not exist."
|
||||
)
|
||||
try:
|
||||
status = await api_payment(payment_hash)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(exc)
|
||||
return {"paid": False}
|
||||
return status
|
||||
|
||||
|
||||
@market_ext.delete("/api/v1/orders/{order_id}")
|
||||
async def api_market_order_delete(
|
||||
order_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
order = await get_market_order(order_id)
|
||||
|
||||
if not order:
|
||||
return {"message": "Order does not exist."}
|
||||
|
||||
if order.wallet != wallet.wallet.id:
|
||||
return {"message": "Not your Order."}
|
||||
|
||||
await delete_market_order(order_id)
|
||||
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
# @market_ext.get("/api/v1/orders/paid/{order_id}")
|
||||
# async def api_market_order_paid(
|
||||
# order_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
# ):
|
||||
# await db.execute(
|
||||
# "UPDATE market.orders SET paid = ? WHERE id = ?",
|
||||
# (
|
||||
# True,
|
||||
# order_id,
|
||||
# ),
|
||||
# )
|
||||
# return "", HTTPStatus.OK
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/order/pubkey/{payment_hash}/{pubkey}")
|
||||
async def api_market_order_pubkey(payment_hash: str, pubkey: str):
|
||||
await set_market_order_pubkey(payment_hash, pubkey)
|
||||
return "", HTTPStatus.OK
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/orders/shipped/{order_id}")
|
||||
async def api_market_order_shipped(
|
||||
order_id, shipped: bool = Query(...), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
await db.execute(
|
||||
"UPDATE market.orders SET shipped = ? WHERE id = ?",
|
||||
(
|
||||
shipped,
|
||||
order_id,
|
||||
),
|
||||
)
|
||||
order = await db.fetchone("SELECT * FROM market.orders WHERE id = ?", (order_id,))
|
||||
|
||||
return order
|
||||
|
||||
|
||||
###List products based on stall id
|
||||
|
||||
|
||||
# @market_ext.get("/api/v1/stall/products/{stall_id}")
|
||||
# async def api_market_stall_products(
|
||||
# stall_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
# ):
|
||||
|
||||
# rows = await db.fetchone("SELECT * FROM market.stalls WHERE id = ?", (stall_id,))
|
||||
# if not rows:
|
||||
# return {"message": "Stall does not exist."}
|
||||
|
||||
# products = db.fetchone("SELECT * FROM market.products WHERE wallet = ?", (rows[1],))
|
||||
# if not products:
|
||||
# return {"message": "No products"}
|
||||
|
||||
# return [products.dict() for products in await get_market_products(rows[1])]
|
||||
|
||||
|
||||
###Check a product has been shipped
|
||||
|
||||
|
||||
# @market_ext.get("/api/v1/stall/checkshipped/{checking_id}")
|
||||
# async def api_market_stall_checkshipped(
|
||||
# checking_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
# ):
|
||||
# rows = await db.fetchone(
|
||||
# "SELECT * FROM market.orders WHERE invoiceid = ?", (checking_id,)
|
||||
# )
|
||||
# return {"shipped": rows["shipped"]}
|
||||
|
||||
|
||||
##
|
||||
# MARKETS
|
||||
##
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/markets")
|
||||
async def api_market_markets(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
# await get_market_market_stalls(market_id="FzpWnMyHQMcRppiGVua4eY")
|
||||
try:
|
||||
return [
|
||||
market.dict() for market in await get_market_markets(wallet.wallet.user)
|
||||
]
|
||||
except:
|
||||
return {"message": "We could not retrieve the markets."}
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/markets/{market_id}/stalls")
|
||||
async def api_market_market_stalls(market_id: str):
|
||||
stall_ids = await get_market_market_stalls(market_id)
|
||||
return stall_ids
|
||||
|
||||
|
||||
@market_ext.post("/api/v1/markets")
|
||||
@market_ext.put("/api/v1/markets/{market_id}")
|
||||
async def api_market_market_create(
|
||||
data: CreateMarket,
|
||||
market_id: str = None,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
):
|
||||
if market_id:
|
||||
market = await get_market_market(market_id)
|
||||
if not market:
|
||||
return {"message": "Market does not exist."}
|
||||
|
||||
if market.usr != wallet.wallet.user:
|
||||
return {"message": "Not your market."}
|
||||
|
||||
market = await update_market_market(market_id, data.name)
|
||||
else:
|
||||
market = await create_market_market(data=data)
|
||||
|
||||
assert market
|
||||
await create_market_market_stalls(market_id=market.id, data=data.stalls)
|
||||
|
||||
return market.dict()
|
||||
|
||||
|
||||
## MESSAGES/CHAT
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/chat/messages/merchant")
|
||||
async def api_get_merchant_messages(
|
||||
orders: str = Query(...), wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
return [msg.dict() for msg in await get_market_chat_by_merchant(orders.split(","))]
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/chat/messages/{room_name}")
|
||||
async def api_get_latest_chat_msg(room_name: str, all_messages: bool = Query(False)):
|
||||
if all_messages:
|
||||
messages = await get_market_chat_messages(room_name)
|
||||
else:
|
||||
messages = await get_market_latest_chat_messages(room_name)
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available():
|
||||
return list(currencies.keys())
|
||||
|
||||
|
||||
@market_ext.get("/api/v1/settings")
|
||||
async def api_get_settings(wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||
user = wallet.wallet.user
|
||||
|
||||
settings = await get_market_settings(user)
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
@market_ext.post("/api/v1/settings")
|
||||
@market_ext.put("/api/v1/settings/{usr}")
|
||||
async def api_set_settings(
|
||||
data: SetSettings,
|
||||
usr: str = None,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
if usr:
|
||||
if usr != wallet.wallet.user:
|
||||
return {"message": "Not your Market."}
|
||||
|
||||
settings = await get_market_settings(user=usr)
|
||||
assert settings
|
||||
|
||||
if settings.user != wallet.wallet.user:
|
||||
return {"message": "Not your Market."}
|
||||
|
||||
return await set_market_settings(usr, data)
|
||||
|
||||
user = wallet.wallet.user
|
||||
|
||||
return await create_market_settings(user, data)
|
|
@ -18,8 +18,6 @@ async def wait_for_paid_invoices():
|
|||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if not payment.extra:
|
||||
return
|
||||
if payment.extra.get("tag") != "nostrnip5":
|
||||
return
|
||||
|
||||
|
|
|
@ -1,15 +1,9 @@
|
|||
import hashlib
|
||||
|
||||
from fastapi.params import Query
|
||||
from lnurl import ( # type: ignore
|
||||
LnurlErrorResponse,
|
||||
LnurlPayActionResponse,
|
||||
LnurlPayResponse,
|
||||
)
|
||||
from fastapi import Query
|
||||
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
|
||||
from lnurl.models import ClearnetUrl, LightningInvoice, MilliSatoshi
|
||||
from starlette.requests import Request
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.extensions.offlineshop.models import Item
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
|
||||
from . import offlineshop_ext
|
||||
|
@ -17,8 +11,8 @@ from .crud import get_item, get_shop
|
|||
|
||||
|
||||
@offlineshop_ext.get("/lnurl/{item_id}", name="offlineshop.lnurl_response")
|
||||
async def lnurl_response(req: Request, item_id: int = Query(...)):
|
||||
item = await get_item(item_id) # type: Item
|
||||
async def lnurl_response(req: Request, item_id: int = Query(...)) -> dict:
|
||||
item = await get_item(item_id)
|
||||
if not item:
|
||||
return {"status": "ERROR", "reason": "Item not found."}
|
||||
|
||||
|
@ -32,9 +26,11 @@ async def lnurl_response(req: Request, item_id: int = Query(...)):
|
|||
) * 1000
|
||||
|
||||
resp = LnurlPayResponse(
|
||||
callback=req.url_for("offlineshop.lnurl_callback", item_id=item.id),
|
||||
min_sendable=price_msat,
|
||||
max_sendable=price_msat,
|
||||
callback=ClearnetUrl(
|
||||
req.url_for("offlineshop.lnurl_callback", item_id=item.id), scheme="https"
|
||||
),
|
||||
minSendable=MilliSatoshi(price_msat),
|
||||
maxSendable=MilliSatoshi(price_msat),
|
||||
metadata=await item.lnurlpay_metadata(),
|
||||
)
|
||||
|
||||
|
@ -43,7 +39,7 @@ async def lnurl_response(req: Request, item_id: int = Query(...)):
|
|||
|
||||
@offlineshop_ext.get("/lnurl/cb/{item_id}", name="offlineshop.lnurl_callback")
|
||||
async def lnurl_callback(request: Request, item_id: int):
|
||||
item = await get_item(item_id) # type: Item
|
||||
item = await get_item(item_id)
|
||||
if not item:
|
||||
return {"status": "ERROR", "reason": "Couldn't find item."}
|
||||
|
||||
|
@ -67,6 +63,7 @@ async def lnurl_callback(request: Request, item_id: int):
|
|||
).dict()
|
||||
|
||||
shop = await get_shop(item.shop)
|
||||
assert shop
|
||||
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
|
@ -77,14 +74,15 @@ async def lnurl_callback(request: Request, item_id: int):
|
|||
extra={"tag": "offlineshop", "item": item.id},
|
||||
)
|
||||
except Exception as exc:
|
||||
return LnurlErrorResponse(reason=exc.message).dict()
|
||||
return LnurlErrorResponse(reason=str(exc)).dict()
|
||||
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=payment_request,
|
||||
success_action=item.success_action(shop, payment_hash, request)
|
||||
if shop.method
|
||||
else None,
|
||||
routes=[],
|
||||
)
|
||||
if shop.method:
|
||||
success_action = item.success_action(shop, payment_hash, request)
|
||||
assert success_action
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=LightningInvoice(payment_request),
|
||||
successAction=success_action,
|
||||
routes=[],
|
||||
)
|
||||
|
||||
return resp.dict()
|
||||
return resp.dict()
|
||||
|
|
|
@ -5,9 +5,9 @@ from collections import OrderedDict
|
|||
from sqlite3 import Row
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from lnurl import encode as lnurl_encode
|
||||
from lnurl.models import ClearnetUrl, Max144Str, UrlAction
|
||||
from lnurl.types import LnurlPayMetadata
|
||||
from pydantic import BaseModel
|
||||
from starlette.requests import Request
|
||||
|
||||
|
@ -119,11 +119,16 @@ class Item(BaseModel):
|
|||
|
||||
def success_action(
|
||||
self, shop: Shop, payment_hash: str, req: Request
|
||||
) -> Optional[LnurlPaySuccessAction]:
|
||||
) -> Optional[UrlAction]:
|
||||
if not shop.wordlist:
|
||||
return None
|
||||
|
||||
return UrlAction(
|
||||
url=req.url_for("offlineshop.confirmation_code", p=payment_hash),
|
||||
description="Open to get the confirmation code for your purchase.",
|
||||
url=ClearnetUrl(
|
||||
req.url_for("offlineshop.confirmation_code", p=payment_hash),
|
||||
scheme="https",
|
||||
),
|
||||
description=Max144Str(
|
||||
"Open to get the confirmation code for your purchase."
|
||||
),
|
||||
)
|
||||
|
|
|
@ -3,8 +3,7 @@ from datetime import datetime
|
|||
from http import HTTPStatus
|
||||
from typing import List
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.params import Depends, Query
|
||||
from fastapi import Depends, HTTPException, Query, Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.crud import get_standalone_payment
|
||||
|
@ -25,10 +24,10 @@ async def index(request: Request, user: User = Depends(check_user_exists)):
|
|||
|
||||
|
||||
@offlineshop_ext.get("/print", response_class=HTMLResponse)
|
||||
async def print_qr_codes(request: Request, items: List[int] = None):
|
||||
async def print_qr_codes(request: Request):
|
||||
items = []
|
||||
for item_id in request.query_params.get("items").split(","):
|
||||
item = await get_item(item_id) # type: Item
|
||||
item = await get_item(item_id)
|
||||
if item:
|
||||
items.append(
|
||||
{
|
||||
|
@ -53,7 +52,8 @@ async def confirmation_code(p: str = Query(...)):
|
|||
|
||||
payment_hash = p
|
||||
await api_payment(payment_hash)
|
||||
payment: Payment = await get_standalone_payment(payment_hash)
|
||||
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
|
@ -72,8 +72,13 @@ async def confirmation_code(p: str = Query(...)):
|
|||
detail="Too much time has passed." + style,
|
||||
)
|
||||
|
||||
item = await get_item(payment.extra.get("item"))
|
||||
assert payment.extra
|
||||
item_id = payment.extra.get("item")
|
||||
assert item_id
|
||||
item = await get_item(item_id)
|
||||
assert item
|
||||
shop = await get_shop(item.shop)
|
||||
assert shop
|
||||
|
||||
return (
|
||||
f"""
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from fastapi import Depends, HTTPException, Query, Request, Response
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||
from pydantic.main import BaseModel
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse # type: ignore
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||
from lnbits.utils.exchange_rates import currencies
|
||||
|
@ -34,6 +30,7 @@ async def api_shop_from_wallet(
|
|||
r: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||
assert shop
|
||||
items = await get_items(shop.id)
|
||||
try:
|
||||
return {
|
||||
|
@ -62,6 +59,7 @@ async def api_add_or_update_item(
|
|||
data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||
assert shop
|
||||
if data.unit != "sat":
|
||||
data.price = data.price * 100
|
||||
if item_id == None:
|
||||
|
@ -71,11 +69,11 @@ async def api_add_or_update_item(
|
|||
data.name,
|
||||
data.description,
|
||||
data.image,
|
||||
data.price,
|
||||
int(data.price),
|
||||
data.unit,
|
||||
data.fiat_base_multiplier,
|
||||
)
|
||||
return HTMLResponse(status_code=HTTPStatus.CREATED)
|
||||
return Response(status_code=HTTPStatus.CREATED)
|
||||
else:
|
||||
await update_item(
|
||||
shop.id,
|
||||
|
@ -83,7 +81,7 @@ async def api_add_or_update_item(
|
|||
data.name,
|
||||
data.description,
|
||||
data.image,
|
||||
data.price,
|
||||
int(data.price),
|
||||
data.unit,
|
||||
data.fiat_base_multiplier,
|
||||
)
|
||||
|
@ -92,6 +90,7 @@ async def api_add_or_update_item(
|
|||
@offlineshop_ext.delete("/api/v1/offlineshop/items/{item_id}")
|
||||
async def api_delete_item(item_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||
assert shop
|
||||
await delete_item_from_shop(shop.id, item_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
@ -107,7 +106,7 @@ async def api_set_method(
|
|||
):
|
||||
method = data.method
|
||||
|
||||
wordlist = data.wordlist.split("\n") if data.wordlist else None
|
||||
wordlist = data.wordlist.split("\n") if data.wordlist else []
|
||||
wordlist = [word.strip() for word in wordlist if word.strip()]
|
||||
|
||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||
|
|
|
@ -12,14 +12,13 @@ from . import db
|
|||
from .helpers import fetch_onchain_balance
|
||||
from .models import Charges, CreateCharge, SatsPayThemes
|
||||
|
||||
###############CHARGES##########################
|
||||
|
||||
|
||||
async def create_charge(user: str, data: CreateCharge) -> Charges:
|
||||
async def create_charge(user: str, data: CreateCharge) -> Optional[Charges]:
|
||||
data = CreateCharge(**data.dict())
|
||||
charge_id = urlsafe_short_hash()
|
||||
if data.onchainwallet:
|
||||
config = await get_config(user)
|
||||
assert config
|
||||
data.extra = json.dumps(
|
||||
{"mempool_endpoint": config.mempool_endpoint, "network": config.network}
|
||||
)
|
||||
|
@ -92,7 +91,7 @@ async def update_charge(charge_id: str, **kwargs) -> Optional[Charges]:
|
|||
return Charges.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_charge(charge_id: str) -> Charges:
|
||||
async def get_charge(charge_id: str) -> Optional[Charges]:
|
||||
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
|
||||
return Charges.from_row(row) if row else None
|
||||
|
||||
|
@ -111,6 +110,7 @@ async def delete_charge(charge_id: str) -> None:
|
|||
|
||||
async def check_address_balance(charge_id: str) -> Optional[Charges]:
|
||||
charge = await get_charge(charge_id)
|
||||
assert charge
|
||||
|
||||
if not charge.paid:
|
||||
if charge.onchainaddress:
|
||||
|
@ -131,7 +131,7 @@ async def check_address_balance(charge_id: str) -> Optional[Charges]:
|
|||
################## SETTINGS ###################
|
||||
|
||||
|
||||
async def save_theme(data: SatsPayThemes, css_id: str = None):
|
||||
async def save_theme(data: SatsPayThemes, css_id: Optional[str]):
|
||||
# insert or update
|
||||
if css_id:
|
||||
await db.execute(
|
||||
|
@ -162,7 +162,7 @@ async def save_theme(data: SatsPayThemes, css_id: str = None):
|
|||
return await get_theme(css_id)
|
||||
|
||||
|
||||
async def get_theme(css_id: str) -> SatsPayThemes:
|
||||
async def get_theme(css_id: str) -> Optional[SatsPayThemes]:
|
||||
row = await db.fetchone("SELECT * FROM satspay.themes WHERE css_id = ?", (css_id,))
|
||||
return SatsPayThemes.from_row(row) if row else None
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ def public_charge(charge: Charges):
|
|||
async def call_webhook(charge: Charges):
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
assert charge.webhook
|
||||
r = await client.post(
|
||||
charge.webhook,
|
||||
json=public_charge(charge),
|
||||
|
@ -54,6 +55,8 @@ async def fetch_onchain_balance(charge: Charges):
|
|||
if charge.config.network == "Testnet"
|
||||
else charge.config.mempool_endpoint
|
||||
)
|
||||
assert endpoint
|
||||
assert charge.onchainaddress
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(endpoint + "/api/address/" + charge.onchainaddress)
|
||||
return r.json()["chain_stats"]["funded_txo_sum"]
|
||||
|
|
|
@ -22,10 +22,12 @@ async def wait_for_paid_invoices():
|
|||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
|
||||
if payment.extra.get("tag") != "charge":
|
||||
# not a charge invoice
|
||||
return
|
||||
|
||||
assert payment.memo
|
||||
charge = await get_charge(payment.memo)
|
||||
if not charge:
|
||||
logger.error("this should never happen", payment)
|
||||
|
@ -33,6 +35,7 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
|
||||
await payment.set_pending(False)
|
||||
charge = await check_address_balance(charge_id=charge.id)
|
||||
assert charge
|
||||
|
||||
if charge.must_call_webhook():
|
||||
resp = await call_webhook(charge)
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Response
|
||||
from fastapi.param_functions import Depends
|
||||
from fastapi import Depends, HTTPException, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Depends, Query
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
|
@ -29,8 +28,6 @@ from .crud import (
|
|||
from .helpers import call_webhook, public_charge
|
||||
from .models import CreateCharge, SatsPayThemes
|
||||
|
||||
#############################CHARGES##########################
|
||||
|
||||
|
||||
@satspay_ext.post("/api/v1/charge")
|
||||
async def api_charge_create(
|
||||
|
@ -38,6 +35,7 @@ async def api_charge_create(
|
|||
):
|
||||
try:
|
||||
charge = await create_charge(user=wallet.wallet.user, data=data)
|
||||
assert charge
|
||||
return {
|
||||
**charge.dict(),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
|
@ -51,13 +49,15 @@ async def api_charge_create(
|
|||
)
|
||||
|
||||
|
||||
@satspay_ext.put("/api/v1/charge/{charge_id}")
|
||||
@satspay_ext.put(
|
||||
"/api/v1/charge/{charge_id}", dependencies=[Depends(require_admin_key)]
|
||||
)
|
||||
async def api_charge_update(
|
||||
data: CreateCharge,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
charge_id=None,
|
||||
charge_id: str,
|
||||
):
|
||||
charge = await update_charge(charge_id=charge_id, data=data)
|
||||
assert charge
|
||||
return charge.dict()
|
||||
|
||||
|
||||
|
@ -78,10 +78,8 @@ async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
|
|||
return ""
|
||||
|
||||
|
||||
@satspay_ext.get("/api/v1/charge/{charge_id}")
|
||||
async def api_charge_retrieve(
|
||||
charge_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
@satspay_ext.get("/api/v1/charge/{charge_id}", dependencies=[Depends(get_key_type)])
|
||||
async def api_charge_retrieve(charge_id: str):
|
||||
charge = await get_charge(charge_id)
|
||||
|
||||
if not charge:
|
||||
|
@ -97,8 +95,8 @@ async def api_charge_retrieve(
|
|||
}
|
||||
|
||||
|
||||
@satspay_ext.delete("/api/v1/charge/{charge_id}")
|
||||
async def api_charge_delete(charge_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
@satspay_ext.delete("/api/v1/charge/{charge_id}", dependencies=[Depends(get_key_type)])
|
||||
async def api_charge_delete(charge_id: str):
|
||||
charge = await get_charge(charge_id)
|
||||
|
||||
if not charge:
|
||||
|
@ -155,7 +153,7 @@ async def api_themes_save(
|
|||
theme = await save_theme(css_id=css_id, data=data)
|
||||
else:
|
||||
data.user = wallet.wallet.user
|
||||
theme = await save_theme(data=data)
|
||||
theme = await save_theme(data=data, css_id="no_id")
|
||||
return theme
|
||||
|
||||
|
||||
|
@ -169,8 +167,8 @@ async def api_themes_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
|
|||
return ""
|
||||
|
||||
|
||||
@satspay_ext.delete("/api/v1/themes/{theme_id}")
|
||||
async def api_theme_delete(theme_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
@satspay_ext.delete("/api/v1/themes/{theme_id}", dependencies=[Depends(get_key_type)])
|
||||
async def api_theme_delete(theme_id):
|
||||
theme = await get_theme(theme_id)
|
||||
|
||||
if not theme:
|
||||
|
|
|
@ -27,7 +27,7 @@ async def wait_for_paid_invoices():
|
|||
|
||||
async def on_invoice_paid(payment: Payment):
|
||||
# (avoid loops)
|
||||
if payment.extra and payment.extra.get("tag") == "scrubed":
|
||||
if payment.extra.get("tag") == "scrubed":
|
||||
# already scrubbed
|
||||
return
|
||||
|
||||
|
|
|
@ -25,15 +25,20 @@ async def get_charge_details(service_id):
|
|||
|
||||
These might be different depending for services implemented in the future.
|
||||
"""
|
||||
details = {"time": 1440}
|
||||
service = await get_service(service_id)
|
||||
assert service
|
||||
|
||||
wallet_id = service.wallet
|
||||
wallet = await get_wallet(wallet_id)
|
||||
assert wallet
|
||||
|
||||
user = wallet.user
|
||||
details["user"] = user
|
||||
details["lnbitswallet"] = wallet_id
|
||||
details["onchainwallet"] = service.onchain
|
||||
return details
|
||||
return {
|
||||
"time": 1440,
|
||||
"user": user,
|
||||
"lnbitswallet": wallet_id,
|
||||
"onchainwallet": service.onchain,
|
||||
}
|
||||
|
||||
|
||||
async def create_donation(
|
||||
|
@ -71,7 +76,7 @@ async def create_donation(
|
|||
return donation
|
||||
|
||||
|
||||
async def post_donation(donation_id: str) -> tuple:
|
||||
async def post_donation(donation_id: str) -> dict:
|
||||
"""Post donations to their respective third party APIs
|
||||
|
||||
If the donation has already been posted, it will not be posted again.
|
||||
|
@ -97,7 +102,6 @@ async def post_donation(donation_id: str) -> tuple:
|
|||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, data=data)
|
||||
status = [s for s in list(HTTPStatus) if s == response.status_code][0]
|
||||
elif service.servicename == "StreamElements":
|
||||
return {"message": "StreamElements not yet supported!"}
|
||||
else:
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
from sqlite3 import Row
|
||||
from typing import Optional
|
||||
|
||||
from fastapi.params import Query
|
||||
from pydantic.main import BaseModel
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateService(BaseModel):
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from fastapi.param_functions import Depends
|
||||
from fastapi import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from fastapi.params import Depends, Query
|
||||
from fastapi import Depends, Query
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
@ -84,6 +84,8 @@ async def api_authenticate_service(
|
|||
"""
|
||||
|
||||
service = await get_service(service_id)
|
||||
assert service
|
||||
|
||||
if service.state != state:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="State doesn't match!"
|
||||
|
@ -113,6 +115,7 @@ async def api_create_donation(data: CreateDonation, request: Request):
|
|||
webhook_base = request.url.scheme + "://" + request.headers["Host"]
|
||||
service_id = data.service
|
||||
service = await get_service(service_id)
|
||||
assert service
|
||||
charge_details = await get_charge_details(service.id)
|
||||
name = data.name if data.name else "Anonymous"
|
||||
|
||||
|
@ -157,7 +160,8 @@ async def api_post_donation(request: Request, data: ValidateDonation):
|
|||
@streamalerts_ext.get("/api/v1/services")
|
||||
async def api_get_services(g: WalletTypeInfo = Depends(get_key_type)):
|
||||
"""Return list of all services assigned to wallet with given invoice key"""
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
user = await get_user(g.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
services = []
|
||||
for wallet_id in wallet_ids:
|
||||
new_services = await get_services(wallet_id)
|
||||
|
@ -170,7 +174,8 @@ async def api_get_donations(g: WalletTypeInfo = Depends(get_key_type)):
|
|||
"""Return list of all donations assigned to wallet with given invoice
|
||||
key
|
||||
"""
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
user = await get_user(g.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
donations = []
|
||||
for wallet_id in wallet_ids:
|
||||
new_donations = await get_donations(wallet_id)
|
||||
|
|
|
@ -20,7 +20,7 @@ async def wait_for_paid_invoices():
|
|||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if not payment.extra or payment.extra.get("tag") != "lnsubdomain":
|
||||
if payment.extra.get("tag") != "lnsubdomain":
|
||||
# not an lnurlp invoice
|
||||
return
|
||||
|
||||
|
|
|
@ -26,7 +26,6 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
if payment.extra.get("tag") != "tpos":
|
||||
return
|
||||
|
||||
tpos = await get_tpos(payment.extra.get("tposId"))
|
||||
tipAmount = payment.extra.get("tipAmount")
|
||||
|
||||
strippedPayment = {
|
||||
|
@ -37,14 +36,23 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
"bolt11": payment.bolt11,
|
||||
}
|
||||
|
||||
await websocketUpdater(payment.extra.get("tposId"), str(strippedPayment))
|
||||
tpos_id = payment.extra.get("tposId")
|
||||
assert tpos_id
|
||||
|
||||
if tipAmount is None:
|
||||
tpos = await get_tpos(tpos_id)
|
||||
assert tpos
|
||||
|
||||
await websocketUpdater(tpos_id, str(strippedPayment))
|
||||
|
||||
if not tipAmount:
|
||||
# no tip amount
|
||||
return
|
||||
|
||||
wallet_id = tpos.tip_wallet
|
||||
assert wallet_id
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=tpos.tip_wallet,
|
||||
wallet_id=wallet_id,
|
||||
amount=int(tipAmount), # sats
|
||||
internal=True,
|
||||
memo=f"tpos tip",
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from fastapi import Depends, Query
|
||||
from lnurl import decode as decode_lnurl
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
@ -25,7 +24,8 @@ async def api_tposs(
|
|||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
return [tpos.dict() for tpos in await get_tposs(wallet_ids)]
|
||||
|
||||
|
@ -58,8 +58,9 @@ async def api_tpos_delete(
|
|||
|
||||
@tpos_ext.post("/api/v1/tposs/{tpos_id}/invoices", status_code=HTTPStatus.CREATED)
|
||||
async def api_tpos_create_invoice(
|
||||
amount: int = Query(..., ge=1), tipAmount: int = None, tpos_id: str = None
|
||||
):
|
||||
tpos_id: str, amount: int = Query(..., ge=1), tipAmount: int = 0
|
||||
) -> dict:
|
||||
|
||||
tpos = await get_tpos(tpos_id)
|
||||
|
||||
if not tpos:
|
||||
|
@ -67,7 +68,7 @@ async def api_tpos_create_invoice(
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
|
||||
)
|
||||
|
||||
if tipAmount:
|
||||
if tipAmount > 0:
|
||||
amount += tipAmount
|
||||
|
||||
try:
|
||||
|
@ -89,7 +90,7 @@ async def api_tpos_create_invoice(
|
|||
|
||||
|
||||
@tpos_ext.get("/api/v1/tposs/{tpos_id}/invoices")
|
||||
async def api_tpos_get_latest_invoices(tpos_id: str = None):
|
||||
async def api_tpos_get_latest_invoices(tpos_id: str):
|
||||
try:
|
||||
payments = [
|
||||
Payment.from_row(row)
|
||||
|
@ -116,7 +117,7 @@ async def api_tpos_get_latest_invoices(tpos_id: str = None):
|
|||
"/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK
|
||||
)
|
||||
async def api_tpos_pay_invoice(
|
||||
lnurl_data: PayLnurlWData, payment_request: str = None, tpos_id: str = None
|
||||
lnurl_data: PayLnurlWData, payment_request: str, tpos_id: str
|
||||
):
|
||||
tpos = await get_tpos(tpos_id)
|
||||
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
from datetime import datetime
|
||||
from typing import List, Optional, Union
|
||||
|
||||
import shortuuid
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
|
@ -8,9 +10,10 @@ from .models import CreateWithdrawData, HashCheck, WithdrawLink
|
|||
|
||||
|
||||
async def create_withdraw_link(
|
||||
data: CreateWithdrawData, wallet_id: str, usescsv: str
|
||||
data: CreateWithdrawData, wallet_id: str
|
||||
) -> WithdrawLink:
|
||||
link_id = urlsafe_short_hash()
|
||||
link_id = urlsafe_short_hash()[:6]
|
||||
available_links = ",".join([str(i) for i in range(data.uses)])
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO withdraw.withdraw_link (
|
||||
|
@ -45,7 +48,7 @@ async def create_withdraw_link(
|
|||
urlsafe_short_hash(),
|
||||
urlsafe_short_hash(),
|
||||
int(datetime.now().timestamp()) + data.wait_time,
|
||||
usescsv,
|
||||
available_links,
|
||||
data.webhook_url,
|
||||
data.webhook_headers,
|
||||
data.webhook_body,
|
||||
|
@ -94,6 +97,26 @@ async def get_withdraw_links(wallet_ids: Union[str, List[str]]) -> List[Withdraw
|
|||
return [WithdrawLink(**row) for row in rows]
|
||||
|
||||
|
||||
async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> None:
|
||||
unique_links = [
|
||||
x.strip()
|
||||
for x in link.usescsv.split(",")
|
||||
if unique_hash != shortuuid.uuid(name=link.id + link.unique_hash + x.strip())
|
||||
]
|
||||
await update_withdraw_link(
|
||||
link.id,
|
||||
usescsv=",".join(unique_links),
|
||||
)
|
||||
|
||||
|
||||
async def increment_withdraw_link(link: WithdrawLink) -> None:
|
||||
await update_withdraw_link(
|
||||
link.id,
|
||||
used=link.used + 1,
|
||||
open_time=link.wait_time + int(datetime.now().timestamp()),
|
||||
)
|
||||
|
||||
|
||||
async def update_withdraw_link(link_id: str, **kwargs) -> Optional[WithdrawLink]:
|
||||
if "is_unique" in kwargs:
|
||||
kwargs["is_unique"] = int(kwargs["is_unique"])
|
||||
|
@ -132,7 +155,7 @@ async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
|
|||
return hashCheck
|
||||
|
||||
|
||||
async def get_hash_check(the_hash: str, lnurl_id: str) -> Optional[HashCheck]:
|
||||
async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
|
||||
rowid = await db.fetchone(
|
||||
"SELECT * FROM withdraw.hash_check WHERE id = ?", (the_hash,)
|
||||
)
|
||||
|
@ -141,10 +164,10 @@ async def get_hash_check(the_hash: str, lnurl_id: str) -> Optional[HashCheck]:
|
|||
)
|
||||
if not rowlnurl:
|
||||
await create_hash_check(the_hash, lnurl_id)
|
||||
return {"lnurl": True, "hash": False}
|
||||
return HashCheck(lnurl=True, hash=False)
|
||||
else:
|
||||
if not rowid:
|
||||
await create_hash_check(the_hash, lnurl_id)
|
||||
return {"lnurl": True, "hash": False}
|
||||
return HashCheck(lnurl=True, hash=False)
|
||||
else:
|
||||
return {"lnurl": True, "hash": True}
|
||||
return HashCheck(lnurl=True, hash=True)
|
||||
|
|
|
@ -1,28 +1,27 @@
|
|||
import json
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
import shortuuid # type: ignore
|
||||
from fastapi import HTTPException
|
||||
from fastapi.param_functions import Query
|
||||
import shortuuid
|
||||
from fastapi import HTTPException, Query, Request, Response
|
||||
from loguru import logger
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.crud import update_payment_extra
|
||||
from lnbits.core.services import pay_invoice
|
||||
|
||||
from . import withdraw_ext
|
||||
from .crud import get_withdraw_link_by_hash, update_withdraw_link
|
||||
|
||||
# FOR LNURLs WHICH ARE NOT UNIQUE
|
||||
from .crud import (
|
||||
get_withdraw_link_by_hash,
|
||||
increment_withdraw_link,
|
||||
remove_unique_withdraw_link,
|
||||
)
|
||||
from .models import WithdrawLink
|
||||
|
||||
|
||||
@withdraw_ext.get(
|
||||
"/api/v1/lnurl/{unique_hash}",
|
||||
response_class=HTMLResponse,
|
||||
response_class=Response,
|
||||
name="withdraw.api_lnurl_response",
|
||||
)
|
||||
async def api_lnurl_response(request: Request, unique_hash):
|
||||
|
@ -53,9 +52,6 @@ async def api_lnurl_response(request: Request, unique_hash):
|
|||
return json.dumps(withdrawResponse)
|
||||
|
||||
|
||||
# CALLBACK
|
||||
|
||||
|
||||
@withdraw_ext.get(
|
||||
"/api/v1/lnurl/cb/{unique_hash}",
|
||||
name="withdraw.api_lnurl_callback",
|
||||
|
@ -99,105 +95,79 @@ async def api_lnurl_callback(
|
|||
detail=f"wait link open_time {link.open_time - now} seconds.",
|
||||
)
|
||||
|
||||
usescsv = ""
|
||||
|
||||
for x in range(1, link.uses - link.used):
|
||||
usecv = link.usescsv.split(",")
|
||||
usescsv += "," + str(usecv[x])
|
||||
usecsvback = usescsv
|
||||
|
||||
found = False
|
||||
if id_unique_hash is not None:
|
||||
useslist = link.usescsv.split(",")
|
||||
for ind, x in enumerate(useslist):
|
||||
tohash = link.id + link.unique_hash + str(x)
|
||||
if id_unique_hash == shortuuid.uuid(name=tohash):
|
||||
found = True
|
||||
useslist.pop(ind)
|
||||
usescsv = ",".join(useslist)
|
||||
if not found:
|
||||
if id_unique_hash:
|
||||
if check_unique_link(link, id_unique_hash):
|
||||
await remove_unique_withdraw_link(link, id_unique_hash)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found."
|
||||
)
|
||||
else:
|
||||
usescsv = usescsv[1:]
|
||||
|
||||
changesback = {
|
||||
"open_time": link.wait_time,
|
||||
"used": link.used,
|
||||
"usescsv": usecsvback,
|
||||
}
|
||||
|
||||
try:
|
||||
changes = {
|
||||
"open_time": link.wait_time + now,
|
||||
"used": link.used + 1,
|
||||
"usescsv": usescsv,
|
||||
}
|
||||
await update_withdraw_link(link.id, **changes)
|
||||
|
||||
payment_request = pr
|
||||
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=link.wallet,
|
||||
payment_request=payment_request,
|
||||
payment_request=pr,
|
||||
max_sat=link.max_withdrawable,
|
||||
extra={"tag": "withdraw"},
|
||||
)
|
||||
|
||||
await increment_withdraw_link(link)
|
||||
if link.webhook_url:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
kwargs = {
|
||||
"json": {
|
||||
"payment_hash": payment_hash,
|
||||
"payment_request": payment_request,
|
||||
"lnurlw": link.id,
|
||||
},
|
||||
"timeout": 40,
|
||||
}
|
||||
if link.webhook_body:
|
||||
kwargs["json"]["body"] = json.loads(link.webhook_body)
|
||||
if link.webhook_headers:
|
||||
kwargs["headers"] = json.loads(link.webhook_headers)
|
||||
|
||||
r: httpx.Response = await client.post(link.webhook_url, **kwargs)
|
||||
await update_payment_extra(
|
||||
payment_hash=payment_hash,
|
||||
extra={
|
||||
"wh_success": r.is_success,
|
||||
"wh_message": r.reason_phrase,
|
||||
"wh_response": r.text,
|
||||
},
|
||||
outgoing=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
# webhook fails shouldn't cause the lnurlw to fail since invoice is already paid
|
||||
logger.error(
|
||||
"Caught exception when dispatching webhook url: " + str(exc)
|
||||
)
|
||||
await update_payment_extra(
|
||||
payment_hash=payment_hash,
|
||||
extra={"wh_success": False, "wh_message": str(exc)},
|
||||
outgoing=True,
|
||||
)
|
||||
|
||||
await dispatch_webhook(link, payment_hash, pr)
|
||||
return {"status": "OK"}
|
||||
|
||||
except Exception as e:
|
||||
await update_withdraw_link(link.id, **changesback)
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=f"withdraw not working. {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def check_unique_link(link: WithdrawLink, unique_hash: str) -> bool:
|
||||
return any(
|
||||
unique_hash == shortuuid.uuid(name=link.id + link.unique_hash + x.strip())
|
||||
for x in link.usescsv.split(",")
|
||||
)
|
||||
|
||||
|
||||
async def dispatch_webhook(
|
||||
link: WithdrawLink, payment_hash: str, payment_request: str
|
||||
) -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r: httpx.Response = await client.post(
|
||||
link.webhook_url,
|
||||
json={
|
||||
"payment_hash": payment_hash,
|
||||
"payment_request": payment_request,
|
||||
"lnurlw": link.id,
|
||||
"body": json.loads(link.webhook_body) if link.webhook_body else "",
|
||||
},
|
||||
headers=json.loads(link.webhook_headers)
|
||||
if link.webhook_headers
|
||||
else None,
|
||||
timeout=40,
|
||||
)
|
||||
await update_payment_extra(
|
||||
payment_hash=payment_hash,
|
||||
extra={
|
||||
"wh_success": r.is_success,
|
||||
"wh_message": r.reason_phrase,
|
||||
"wh_response": r.text,
|
||||
},
|
||||
outgoing=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
# webhook fails shouldn't cause the lnurlw to fail since invoice is already paid
|
||||
logger.error("Caught exception when dispatching webhook url: " + str(exc))
|
||||
await update_payment_extra(
|
||||
payment_hash=payment_hash,
|
||||
extra={"wh_success": False, "wh_message": str(exc)},
|
||||
outgoing=True,
|
||||
)
|
||||
|
||||
|
||||
# FOR LNURLs WHICH ARE UNIQUE
|
||||
|
||||
|
||||
@withdraw_ext.get(
|
||||
"/api/v1/lnurl/{unique_hash}/{id_unique_hash}",
|
||||
response_class=HTMLResponse,
|
||||
response_class=Response,
|
||||
name="withdraw.api_lnurl_multi_response",
|
||||
)
|
||||
async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash):
|
||||
|
@ -213,14 +183,7 @@ async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
|
||||
)
|
||||
|
||||
useslist = link.usescsv.split(",")
|
||||
found = False
|
||||
for x in useslist:
|
||||
tohash = link.id + link.unique_hash + str(x)
|
||||
if id_unique_hash == shortuuid.uuid(name=tohash):
|
||||
found = True
|
||||
|
||||
if not found:
|
||||
if not check_unique_link(link, id_unique_hash):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
|
||||
)
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
from sqlite3 import Row
|
||||
|
||||
import shortuuid # type: ignore
|
||||
from fastapi.param_functions import Query
|
||||
import shortuuid
|
||||
from fastapi import Query
|
||||
from lnurl import Lnurl, LnurlWithdrawResponse
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from lnurl import encode as lnurl_encode
|
||||
from lnurl.models import ClearnetUrl, MilliSatoshi
|
||||
from pydantic import BaseModel
|
||||
from starlette.requests import Request
|
||||
|
||||
|
@ -67,18 +66,14 @@ class WithdrawLink(BaseModel):
|
|||
name="withdraw.api_lnurl_callback", unique_hash=self.unique_hash
|
||||
)
|
||||
return LnurlWithdrawResponse(
|
||||
callback=url,
|
||||
callback=ClearnetUrl(url, scheme="https"),
|
||||
k1=self.k1,
|
||||
min_withdrawable=self.min_withdrawable * 1000,
|
||||
max_withdrawable=self.max_withdrawable * 1000,
|
||||
default_description=self.title,
|
||||
minWithdrawable=MilliSatoshi(self.min_withdrawable * 1000),
|
||||
maxWithdrawable=MilliSatoshi(self.max_withdrawable * 1000),
|
||||
defaultDescription=self.title,
|
||||
)
|
||||
|
||||
|
||||
class HashCheck(BaseModel):
|
||||
id: str
|
||||
lnurl_id: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Hash":
|
||||
return cls(**dict(row))
|
||||
hash: bool
|
||||
lnurl: bool
|
||||
|
|
|
@ -2,10 +2,8 @@ from http import HTTPStatus
|
|||
from io import BytesIO
|
||||
|
||||
import pyqrcode
|
||||
from fastapi import Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from fastapi import Depends, HTTPException, Query, Request
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
|
@ -30,7 +27,8 @@ async def api_links(
|
|||
wallet_ids = [wallet.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
user = await get_user(wallet.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
try:
|
||||
return [
|
||||
|
@ -47,7 +45,7 @@ async def api_links(
|
|||
|
||||
@withdraw_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||
async def api_link_retrieve(
|
||||
link_id, request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
link_id: str, request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
link = await get_withdraw_link(link_id, 0)
|
||||
|
||||
|
@ -68,7 +66,7 @@ async def api_link_retrieve(
|
|||
async def api_link_create_or_update(
|
||||
req: Request,
|
||||
data: CreateWithdrawData,
|
||||
link_id: str = None,
|
||||
link_id: str = Query(None),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
if data.uses > 250:
|
||||
|
@ -85,14 +83,6 @@ async def api_link_create_or_update(
|
|||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
usescsv = ""
|
||||
for i in range(data.uses):
|
||||
if data.is_unique:
|
||||
usescsv += "," + str(i + 1)
|
||||
else:
|
||||
usescsv += "," + str(1)
|
||||
usescsv = usescsv[1:]
|
||||
|
||||
if link_id:
|
||||
link = await get_withdraw_link(link_id, 0)
|
||||
if not link:
|
||||
|
@ -103,13 +93,10 @@ async def api_link_create_or_update(
|
|||
raise HTTPException(
|
||||
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
link = await update_withdraw_link(
|
||||
link_id, **data.dict(), usescsv=usescsv, used=0
|
||||
)
|
||||
link = await update_withdraw_link(link_id, **data.dict())
|
||||
else:
|
||||
link = await create_withdraw_link(
|
||||
wallet_id=wallet.wallet.id, data=data, usescsv=usescsv
|
||||
)
|
||||
link = await create_withdraw_link(wallet_id=wallet.wallet.id, data=data)
|
||||
assert link
|
||||
return {**link.dict(), **{"lnurl": link.lnurl(req)}}
|
||||
|
||||
|
||||
|
@ -131,9 +118,11 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admi
|
|||
return {"success": True}
|
||||
|
||||
|
||||
@withdraw_ext.get("/api/v1/links/{the_hash}/{lnurl_id}", status_code=HTTPStatus.OK)
|
||||
async def api_hash_retrieve(
|
||||
the_hash, lnurl_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
@withdraw_ext.get(
|
||||
"/api/v1/links/{the_hash}/{lnurl_id}",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(get_key_type)],
|
||||
)
|
||||
async def api_hash_retrieve(the_hash, lnurl_id):
|
||||
hashCheck = await get_hash_check(the_hash, lnurl_id)
|
||||
return hashCheck
|
||||
|
|
|
@ -91,20 +91,9 @@ files = "lnbits"
|
|||
exclude = """(?x)(
|
||||
^lnbits/extensions/bleskomat.
|
||||
| ^lnbits/extensions/boltz.
|
||||
| ^lnbits/extensions/boltcards.
|
||||
| ^lnbits/extensions/gerty.
|
||||
| ^lnbits/extensions/invoices.
|
||||
| ^lnbits/extensions/livestream.
|
||||
| ^lnbits/extensions/lnaddress.
|
||||
| ^lnbits/extensions/lndhub.
|
||||
| ^lnbits/extensions/lnurldevice.
|
||||
| ^lnbits/extensions/lnurlp.
|
||||
| ^lnbits/extensions/offlineshop.
|
||||
| ^lnbits/extensions/satspay.
|
||||
| ^lnbits/extensions/streamalerts.
|
||||
| ^lnbits/extensions/tpos.
|
||||
| ^lnbits/extensions/watchonly.
|
||||
| ^lnbits/extensions/withdraw.
|
||||
| ^lnbits/wallets/lnd_grpc_files.
|
||||
)"""
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user