Merge pull request #343 from arcbtc/FastAPI

Offline shop working
This commit is contained in:
Arc 2021-09-17 11:29:43 +01:00 committed by GitHub
commit 7af04e493f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 152 additions and 101 deletions

View File

@ -1,3 +1,17 @@
## Defining a route with path parameters
**old:**
```python
# with <>
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
```
**new:**
```python
# with curly braces: {}
@offlineshop_ext.get("/lnurl/{item_id}")
```
## Check if a user exists and access user object
**old:**
```python

View File

@ -95,9 +95,14 @@ def register_routes(app: FastAPI) -> None:
try:
ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}")
ext_route = getattr(ext_module, f"{ext.code}_ext")
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
for s in ext_statics:
app.mount(s["path"], s["app"], s["name"])
app.include_router(ext_route)
except Exception:
except Exception as e:
print(str(e))
raise ImportError(
f"Please make sure that the extension `{ext.code}` follows conventions."
)

View File

@ -1,15 +1,41 @@
from fastapi import APIRouter
from fastapi import APIRouter, FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.routing import Mount
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_offlineshop")
offlineshop_static_files = [
{
"path": "/offlineshop/static",
"app": StaticFiles(directory="lnbits/extensions/offlineshop/static"),
"name": "offlineshop_static",
}
]
offlineshop_ext: APIRouter = APIRouter(
prefix="/Extension",
tags=["Offlineshop"]
prefix="/offlineshop",
tags=["Offlineshop"],
# routes=[
# Mount(
# "/static",
# app=StaticFiles(directory="lnbits/extensions/offlineshop/static"),
# name="offlineshop_static",
# )
# ],
)
from .views_api import * # noqa
from .views import * # noqa
def offlineshop_renderer():
return template_renderer(
[
"lnbits/extensions/offlineshop/templates",
]
)
from .lnurl import * # noqa
from .views import * # noqa
from .views_api import * # noqa

View File

@ -1,4 +1,8 @@
import hashlib
from fastapi.params import Query
from starlette.requests import Request
from lnbits.helpers import url_for
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
from lnbits.core.services import create_invoice
@ -8,8 +12,8 @@ from . import offlineshop_ext
from .crud import get_shop, get_item
@offlineshop_ext.get("/lnurl/<item_id>")
async def lnurl_response(item_id):
@offlineshop_ext.get("/lnurl/{item_id}", name="offlineshop.lnurl_response")
async def lnurl_response(item_id: int = Query(...)):
item = await get_item(item_id)
if not item:
return {"status": "ERROR", "reason": "Item not found."}
@ -34,7 +38,7 @@ async def lnurl_response(item_id):
@offlineshop_ext.get("/lnurl/cb/<item_id>")
async def lnurl_callback(item_id):
async def lnurl_callback(request: Request, item_id: int):
item = await get_item(item_id)
if not item:
return {"status": "ERROR", "reason": "Couldn't find item."}
@ -51,12 +55,12 @@ async def lnurl_callback(item_id):
amount_received = int(request.args.get("amount") or 0)
if amount_received < min:
return LnurlErrorResponse(
reason=f"Amount {amount_received} is smaller than minimum {min}."
).dict()
reason=f"Amount {amount_received} is smaller than minimum {min}."
).dict()
elif amount_received > max:
return LnurlErrorResponse(
reason=f"Amount {amount_received} is greater than maximum {max}."
).dict()
reason=f"Amount {amount_received} is greater than maximum {max}."
).dict()
shop = await get_shop(item.shop)
@ -75,7 +79,7 @@ async def lnurl_callback(item_id):
resp = LnurlPayActionResponse(
pr=payment_request,
success_action=item.success_action(shop, payment_hash) if shop.method else None,
success_action=item.success_action(shop, payment_hash, request) if shop.method else None,
routes=[],
)

View File

@ -8,6 +8,7 @@ from lnurl import encode as lnurl_encode # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
from pydantic import BaseModel
from starlette.requests import Request
from .helpers import totp
shop_counters: Dict = {}
@ -82,20 +83,16 @@ class Item(BaseModel):
id: int
name: str
description: str
image: str
image: Optional[str]
enabled: bool
price: int
unit: str
@property
def lnurl(self) -> str:
return lnurl_encode(
url_for("offlineshop.lnurl_response", item_id=self.id, _external=True)
def values(self, req: Request):
values = self.dict()
values["lnurl"] = lnurl_encode(
req.url_for("offlineshop.lnurl_response", item_id=self.id)
)
def values(self):
values = self._asdict()
values["lnurl"] = self.lnurl
return values
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
@ -107,14 +104,14 @@ class Item(BaseModel):
return LnurlPayMetadata(json.dumps(metadata))
def success_action(
self, shop: Shop, payment_hash: str
self, shop: Shop, payment_hash: str, req: Request
) -> Optional[LnurlPaySuccessAction]:
if not shop.wordlist:
return None
return UrlAction(
url=url_for(
"offlineshop.confirmation_code", p=payment_hash, _external=True
url=req.url_for(
"offlineshop.confirmation_code", p=payment_hash
),
description="Open to get the confirmation code for your purchase.",
)

View File

@ -64,7 +64,7 @@
<code
>curl -X GET {{ request.url_root
}}/offlineshop/api/v1/offlineshop/items -H "Content-Type:
application/json" -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -d
application/json" -H "X-Api-Key: {{ user.wallets[0].inkey }}" -d
'{"name": &lt;string&gt;, "description": &lt;string&gt;, "image":
&lt;data-uri string&gt;, "price": &lt;integer&gt;, "unit": &lt;"sat"
or "USD"&gt;}'
@ -97,7 +97,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
"X-Api-Key: {{ user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
@ -120,7 +120,7 @@
>curl -X GET {{ request.url_root
}}/offlineshop/api/v1/offlineshop/items/&lt;item_id&gt; -H
"Content-Type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].inkey }}" -d '{"name": &lt;string&gt;,
user.wallets[0].inkey }}" -d '{"name": &lt;string&gt;,
"description": &lt;string&gt;, "image": &lt;data-uri string&gt;,
"price": &lt;integer&gt;, "unit": &lt;"sat" or "USD"&gt;}'
</code>
@ -139,7 +139,7 @@
<code
>curl -X GET {{ request.url_root
}}/offlineshop/api/v1/offlineshop/items/&lt;item_id&gt; -H "X-Api-Key:
{{ g.user.wallets[0].inkey }}"
{{ user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>

View File

@ -331,5 +331,5 @@
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
<script src="/offlineshop/static/js/index.js"></script>
<script src="{{ url_for('offlineshop_static', path='js/index.js') }}"></script>
{% endblock %}

View File

@ -2,25 +2,24 @@ import time
from datetime import datetime
from http import HTTPStatus
from fastapi.params import Depends
from starlette.responses import HTMLResponse
from lnbits.decorators import check_user_exists
from lnbits.core.models import Payment
from lnbits.core.models import Payment, User
from lnbits.core.crud import get_standalone_payment
from . import offlineshop_ext
from . import offlineshop_ext, offlineshop_renderer
from .crud import get_item, get_shop
from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
@offlineshop_ext.get("/")
# @validate_uuids(["usr"], required=True)
# @check_user_exists()
async def index(request: Request):
return await templates.TemplateResponse("offlineshop/index.html", {"request": request,"user":g.user})
from fastapi import Request, HTTPException
@offlineshop_ext.get("/print")
@offlineshop_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return offlineshop_renderer().TemplateResponse("offlineshop/index.html", {"request": request, "user": user.dict()})
@offlineshop_ext.get("/print", response_class=HTMLResponse)
async def print_qr_codes(request: Request):
items = []
for item_id in request.args.get("items").split(","):
@ -34,29 +33,32 @@ async def print_qr_codes(request: Request):
}
)
return await templates.TemplateResponse("offlineshop/print.html", {"request": request,"items":items})
return offlineshop_renderer().TemplateResponse("offlineshop/print.html", {"request": request,"items":items})
@offlineshop_ext.get("/confirmation")
async def confirmation_code():
async def confirmation_code(p: str):
style = "<style>* { font-size: 100px}</style>"
payment_hash = request.args.get("p")
payment_hash = p
payment: Payment = await get_standalone_payment(payment_hash)
if not payment:
return (
f"Couldn't find the payment {payment_hash}." + style,
HTTPStatus.NOT_FOUND,
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Couldn't find the payment {payment_hash}." + style
)
if payment.pending:
return (
f"Payment {payment_hash} wasn't received yet. Please try again in a minute."
+ style,
HTTPStatus.PAYMENT_REQUIRED,
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail=f"Payment {payment_hash} wasn't received yet. Please try again in a minute." + style
)
if payment.time + 60 * 15 < time.time():
return "too much time has passed." + style
raise HTTPException(
status_code=HTTPStatus.REQUEST_TIMEOUT,
detail="Too much time has passed." + style
)
item = await get_item(payment.extra.get("item"))
shop = await get_shop(item.shop)

View File

@ -1,10 +1,16 @@
from typing import Optional
import json
from typing import List, Optional
from fastapi.params import Depends
from pydantic.main import BaseModel
from http import HTTPStatus
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse # type: ignore
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.utils.exchange_rates import currencies
from lnbits.requestvars import g
@ -22,46 +28,43 @@ from .models import ShopCounter
@offlineshop_ext.get("/api/v1/currencies")
async def api_list_currencies_available():
return jsonify(list(currencies.keys()))
return json.dumps(list(currencies.keys()))
@offlineshop_ext.get("/api/v1/offlineshop")
@api_check_wallet_key("invoice")
async def api_shop_from_wallet():
shop = await get_or_create_shop_by_wallet(g().wallet.id)
# @api_check_wallet_key("invoice")
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)
items = await get_items(shop.id)
try:
return (
{
**shop._asdict(),
**{
"otp_key": shop.otp_key,
"items": [item.values() for item in items],
},
},
HTTPStatus.OK,
)
return {
**shop.dict(),
**{
"otp_key": shop.otp_key,
"items": [item.values(r) for item in items],
},
}
except LnurlInvalidUrl:
return (
{
"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."
},
HTTPStatus.UPGRADE_REQUIRED,
raise HTTPException(
status_code=HTTPStatus.UPGRADE_REQUIRED,
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
)
class CreateItemsData(BaseModel):
name: str
name: str
description: str
image: Optional[str]
price: int
unit: str
image: Optional[str]
price: int
unit: str
@offlineshop_ext.post("/api/v1/offlineshop/items")
@offlineshop_ext.put("/api/v1/offlineshop/items/{item_id}")
@api_check_wallet_key("invoice")
async def api_add_or_update_item(data: CreateItemsData, item_id=None):
shop = await get_or_create_shop_by_wallet(g().wallet.id)
# @api_check_wallet_key("invoice")
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)
if item_id == None:
await add_item(
shop.id,
@ -71,7 +74,7 @@ async def api_add_or_update_item(data: CreateItemsData, item_id=None):
data.price,
data.unit,
)
return "", HTTPStatus.CREATED
return HTMLResponse(status_code=HTTPStatus.CREATED)
else:
await update_item(
shop.id,
@ -82,36 +85,35 @@ async def api_add_or_update_item(data: CreateItemsData, item_id=None):
data.price,
data.unit,
)
return "", HTTPStatus.OK
@offlineshop_ext.delete("/api/v1/offlineshop/items/{item_id}")
@api_check_wallet_key("invoice")
async def api_delete_item(item_id):
shop = await get_or_create_shop_by_wallet(g().wallet.id)
# @api_check_wallet_key("invoice")
async def api_delete_item(item_id, wallet: WalletTypeInfo = Depends(get_key_type)):
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
await delete_item_from_shop(shop.id, item_id)
return "", HTTPStatus.NO_CONTENT
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
class CreateMethodData(BaseModel):
method: str
method: str
wordlist: Optional[str]
@offlineshop_ext.put("/api/v1/offlineshop/method")
@api_check_wallet_key("invoice")
async def api_set_method(data: CreateMethodData):
# @api_check_wallet_key("invoice")
async def api_set_method(data: CreateMethodData, wallet: WalletTypeInfo = Depends(get_key_type)):
method = data.method
wordlist = data.wordlist.split("\n") if data.wordlist else None
wordlist = [word.strip() for word in wordlist if word.strip()]
shop = await get_or_create_shop_by_wallet(g().wallet.id)
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
if not shop:
return "", HTTPStatus.NOT_FOUND
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
updated_shop = await set_method(shop.id, method, "\n".join(wordlist))
if not updated_shop:
return "", HTTPStatus.NOT_FOUND
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
ShopCounter.reset(updated_shop)
return "", HTTPStatus.OK

View File

@ -149,9 +149,9 @@ def url_for(
url = f"{base}{endpoint}{url_params}"
return url
def template_renderer() -> Jinja2Templates:
def template_renderer(additional_folders: List = []) -> Jinja2Templates:
t = Jinja2Templates(
loader=jinja2.FileSystemLoader(["lnbits/templates", "lnbits/core/templates"]),
loader=jinja2.FileSystemLoader(["lnbits/templates", "lnbits/core/templates", *additional_folders]),
)
t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE
t.env.globals["SITE_TAGLINE"] = settings.LNBITS_SITE_TAGLINE

View File

@ -5,6 +5,7 @@ import typing
from starlette import templating
from starlette.datastructures import QueryParams
from starlette.requests import Request
from lnbits.requestvars import g
@ -22,8 +23,8 @@ class Jinja2Templates(templating.Jinja2Templates):
def get_environment(self, loader: "jinja2.BaseLoader") -> "jinja2.Environment":
@jinja2.contextfunction
def url_for(context: dict, name: str, **path_params: typing.Any) -> str:
request = context["request"]
return request.url_for(name, **path_params)
request: Request = context["request"] # type: starlette.requests.Request
return request.app.url_path_for(name, **path_params)
def url_params_update(init: QueryParams, **new: typing.Any) -> QueryParams:
values = dict(init)