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
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 ## Check if a user exists and access user object
**old:** **old:**
```python ```python

View File

@@ -95,9 +95,14 @@ def register_routes(app: FastAPI) -> None:
try: try:
ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}") ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}")
ext_route = getattr(ext_module, f"{ext.code}_ext") 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) app.include_router(ext_route)
except Exception: except Exception as e:
print(str(e))
raise ImportError( raise ImportError(
f"Please make sure that the extension `{ext.code}` follows conventions." 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.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_offlineshop") db = Database("ext_offlineshop")
offlineshop_static_files = [
{
"path": "/offlineshop/static",
"app": StaticFiles(directory="lnbits/extensions/offlineshop/static"),
"name": "offlineshop_static",
}
]
offlineshop_ext: APIRouter = APIRouter( offlineshop_ext: APIRouter = APIRouter(
prefix="/Extension", prefix="/offlineshop",
tags=["Offlineshop"] tags=["Offlineshop"],
# routes=[
# Mount(
# "/static",
# app=StaticFiles(directory="lnbits/extensions/offlineshop/static"),
# name="offlineshop_static",
# )
# ],
) )
from .views_api import * # noqa def offlineshop_renderer():
from .views import * # noqa return template_renderer(
[
"lnbits/extensions/offlineshop/templates",
]
)
from .lnurl import * # noqa from .lnurl import * # noqa
from .views import * # noqa
from .views_api import * # noqa

View File

@@ -1,4 +1,8 @@
import hashlib 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 lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
from lnbits.core.services import create_invoice from lnbits.core.services import create_invoice
@@ -8,8 +12,8 @@ from . import offlineshop_ext
from .crud import get_shop, get_item from .crud import get_shop, get_item
@offlineshop_ext.get("/lnurl/<item_id>") @offlineshop_ext.get("/lnurl/{item_id}", name="offlineshop.lnurl_response")
async def lnurl_response(item_id): async def lnurl_response(item_id: int = Query(...)):
item = await get_item(item_id) item = await get_item(item_id)
if not item: if not item:
return {"status": "ERROR", "reason": "Item not found."} return {"status": "ERROR", "reason": "Item not found."}
@@ -34,7 +38,7 @@ async def lnurl_response(item_id):
@offlineshop_ext.get("/lnurl/cb/<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) item = await get_item(item_id)
if not item: if not item:
return {"status": "ERROR", "reason": "Couldn't find 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) amount_received = int(request.args.get("amount") or 0)
if amount_received < min: if amount_received < min:
return LnurlErrorResponse( return LnurlErrorResponse(
reason=f"Amount {amount_received} is smaller than minimum {min}." reason=f"Amount {amount_received} is smaller than minimum {min}."
).dict() ).dict()
elif amount_received > max: elif amount_received > max:
return LnurlErrorResponse( return LnurlErrorResponse(
reason=f"Amount {amount_received} is greater than maximum {max}." reason=f"Amount {amount_received} is greater than maximum {max}."
).dict() ).dict()
shop = await get_shop(item.shop) shop = await get_shop(item.shop)
@@ -75,7 +79,7 @@ async def lnurl_callback(item_id):
resp = LnurlPayActionResponse( resp = LnurlPayActionResponse(
pr=payment_request, 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=[], 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.types import LnurlPayMetadata # type: ignore
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
from pydantic import BaseModel from pydantic import BaseModel
from starlette.requests import Request
from .helpers import totp from .helpers import totp
shop_counters: Dict = {} shop_counters: Dict = {}
@@ -82,20 +83,16 @@ class Item(BaseModel):
id: int id: int
name: str name: str
description: str description: str
image: str image: Optional[str]
enabled: bool enabled: bool
price: int price: int
unit: str unit: str
@property def values(self, req: Request):
def lnurl(self) -> str: values = self.dict()
return lnurl_encode( values["lnurl"] = lnurl_encode(
url_for("offlineshop.lnurl_response", item_id=self.id, _external=True) req.url_for("offlineshop.lnurl_response", item_id=self.id)
) )
def values(self):
values = self._asdict()
values["lnurl"] = self.lnurl
return values return values
async def lnurlpay_metadata(self) -> LnurlPayMetadata: async def lnurlpay_metadata(self) -> LnurlPayMetadata:
@@ -107,14 +104,14 @@ class Item(BaseModel):
return LnurlPayMetadata(json.dumps(metadata)) return LnurlPayMetadata(json.dumps(metadata))
def success_action( def success_action(
self, shop: Shop, payment_hash: str self, shop: Shop, payment_hash: str, req: Request
) -> Optional[LnurlPaySuccessAction]: ) -> Optional[LnurlPaySuccessAction]:
if not shop.wordlist: if not shop.wordlist:
return None return None
return UrlAction( return UrlAction(
url=url_for( url=req.url_for(
"offlineshop.confirmation_code", p=payment_hash, _external=True "offlineshop.confirmation_code", p=payment_hash
), ),
description="Open to get the confirmation code for your purchase.", description="Open to get the confirmation code for your purchase.",
) )

View File

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

View File

@@ -331,5 +331,5 @@
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script> <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 %} {% endblock %}

View File

@@ -2,25 +2,24 @@ import time
from datetime import datetime from datetime import datetime
from http import HTTPStatus from http import HTTPStatus
from fastapi.params import Depends
from starlette.responses import HTMLResponse
from lnbits.decorators import check_user_exists 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 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 .crud import get_item, get_shop
from fastapi import FastAPI, Request from fastapi import Request, HTTPException
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})
@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): async def print_qr_codes(request: Request):
items = [] items = []
for item_id in request.args.get("items").split(","): 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") @offlineshop_ext.get("/confirmation")
async def confirmation_code(): async def confirmation_code(p: str):
style = "<style>* { font-size: 100px}</style>" style = "<style>* { font-size: 100px}</style>"
payment_hash = request.args.get("p") payment_hash = p
payment: Payment = await get_standalone_payment(payment_hash) payment: Payment = await get_standalone_payment(payment_hash)
if not payment: if not payment:
return ( raise HTTPException(
f"Couldn't find the payment {payment_hash}." + style, status_code=HTTPStatus.NOT_FOUND,
HTTPStatus.NOT_FOUND, detail=f"Couldn't find the payment {payment_hash}." + style
) )
if payment.pending: if payment.pending:
return ( raise HTTPException(
f"Payment {payment_hash} wasn't received yet. Please try again in a minute." status_code=HTTPStatus.PAYMENT_REQUIRED,
+ style, detail=f"Payment {payment_hash} wasn't received yet. Please try again in a minute." + style
HTTPStatus.PAYMENT_REQUIRED,
) )
if payment.time + 60 * 15 < time.time(): 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")) item = await get_item(payment.extra.get("item"))
shop = await get_shop(item.shop) 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 pydantic.main import BaseModel
from http import HTTPStatus 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.utils.exchange_rates import currencies
from lnbits.requestvars import g from lnbits.requestvars import g
@@ -22,46 +28,43 @@ from .models import ShopCounter
@offlineshop_ext.get("/api/v1/currencies") @offlineshop_ext.get("/api/v1/currencies")
async def api_list_currencies_available(): async def api_list_currencies_available():
return jsonify(list(currencies.keys())) return json.dumps(list(currencies.keys()))
@offlineshop_ext.get("/api/v1/offlineshop") @offlineshop_ext.get("/api/v1/offlineshop")
@api_check_wallet_key("invoice") # @api_check_wallet_key("invoice")
async def api_shop_from_wallet(): async def api_shop_from_wallet(r: Request, wallet: WalletTypeInfo = Depends(get_key_type)):
shop = await get_or_create_shop_by_wallet(g().wallet.id) shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
items = await get_items(shop.id) items = await get_items(shop.id)
try: try:
return ( return {
{ **shop.dict(),
**shop._asdict(), **{
**{ "otp_key": shop.otp_key,
"otp_key": shop.otp_key, "items": [item.values(r) for item in items],
"items": [item.values() for item in items], },
}, }
},
HTTPStatus.OK,
)
except LnurlInvalidUrl: except LnurlInvalidUrl:
return ( raise HTTPException(
{ status_code=HTTPStatus.UPGRADE_REQUIRED,
"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor." detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
},
HTTPStatus.UPGRADE_REQUIRED,
) )
class CreateItemsData(BaseModel): class CreateItemsData(BaseModel):
name: str name: str
description: str description: str
image: Optional[str] image: Optional[str]
price: int price: int
unit: str unit: str
@offlineshop_ext.post("/api/v1/offlineshop/items") @offlineshop_ext.post("/api/v1/offlineshop/items")
@offlineshop_ext.put("/api/v1/offlineshop/items/{item_id}") @offlineshop_ext.put("/api/v1/offlineshop/items/{item_id}")
@api_check_wallet_key("invoice") # @api_check_wallet_key("invoice")
async def api_add_or_update_item(data: CreateItemsData, item_id=None): 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(g().wallet.id) shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
if item_id == None: if item_id == None:
await add_item( await add_item(
shop.id, shop.id,
@@ -71,7 +74,7 @@ async def api_add_or_update_item(data: CreateItemsData, item_id=None):
data.price, data.price,
data.unit, data.unit,
) )
return "", HTTPStatus.CREATED return HTMLResponse(status_code=HTTPStatus.CREATED)
else: else:
await update_item( await update_item(
shop.id, shop.id,
@@ -82,36 +85,35 @@ async def api_add_or_update_item(data: CreateItemsData, item_id=None):
data.price, data.price,
data.unit, data.unit,
) )
return "", HTTPStatus.OK
@offlineshop_ext.delete("/api/v1/offlineshop/items/{item_id}") @offlineshop_ext.delete("/api/v1/offlineshop/items/{item_id}")
@api_check_wallet_key("invoice") # @api_check_wallet_key("invoice")
async def api_delete_item(item_id): async def api_delete_item(item_id, wallet: WalletTypeInfo = Depends(get_key_type)):
shop = await get_or_create_shop_by_wallet(g().wallet.id) shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
await delete_item_from_shop(shop.id, item_id) await delete_item_from_shop(shop.id, item_id)
return "", HTTPStatus.NO_CONTENT raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
class CreateMethodData(BaseModel): class CreateMethodData(BaseModel):
method: str method: str
wordlist: Optional[str] wordlist: Optional[str]
@offlineshop_ext.put("/api/v1/offlineshop/method") @offlineshop_ext.put("/api/v1/offlineshop/method")
@api_check_wallet_key("invoice") # @api_check_wallet_key("invoice")
async def api_set_method(data: CreateMethodData): async def api_set_method(data: CreateMethodData, wallet: WalletTypeInfo = Depends(get_key_type)):
method = data.method method = data.method
wordlist = data.wordlist.split("\n") if data.wordlist else None wordlist = data.wordlist.split("\n") if data.wordlist else None
wordlist = [word.strip() for word in wordlist if word.strip()] 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: 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)) updated_shop = await set_method(shop.id, method, "\n".join(wordlist))
if not updated_shop: if not updated_shop:
return "", HTTPStatus.NOT_FOUND raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
ShopCounter.reset(updated_shop) ShopCounter.reset(updated_shop)
return "", HTTPStatus.OK

View File

@@ -149,9 +149,9 @@ def url_for(
url = f"{base}{endpoint}{url_params}" url = f"{base}{endpoint}{url_params}"
return url return url
def template_renderer() -> Jinja2Templates: def template_renderer(additional_folders: List = []) -> Jinja2Templates:
t = 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_TITLE"] = settings.LNBITS_SITE_TITLE
t.env.globals["SITE_TAGLINE"] = settings.LNBITS_SITE_TAGLINE t.env.globals["SITE_TAGLINE"] = settings.LNBITS_SITE_TAGLINE

View File

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