diff --git a/lnbits/extensions/streamalerts/README.md b/lnbits/extensions/streamalerts/README.md new file mode 100644 index 00000000..726ffe76 --- /dev/null +++ b/lnbits/extensions/streamalerts/README.md @@ -0,0 +1,39 @@ +

Stream Alerts

+

Integrate Bitcoin Donations into your livestream alerts

+The StreamAlerts extension allows you to integrate Bitcoin Lightning (and on-chain) paymnents in to your existing Streamlabs alerts! + +![image](https://user-images.githubusercontent.com/28876473/127759038-aceb2503-6cff-4061-8b81-c769438ebcaa.png) + +

How to set it up

+ +At the moment, the only service that has an open API to work with is Streamlabs, so this setup requires linking your Twitch/YouTube/Facebook account to Streamlabs. + +1. Log into [Streamlabs](https://streamlabs.com/login?r=https://streamlabs.com/dashboard). +1. Navigate to the API settings page to register an App: +![image](https://user-images.githubusercontent.com/28876473/127759145-710d53b6-3c19-4815-812a-9a6279d1b8bb.png) +![image](https://user-images.githubusercontent.com/28876473/127759182-da8a27cb-bb59-48fa-868e-c8892080ae98.png) +![image](https://user-images.githubusercontent.com/28876473/127759201-7c28e9f1-6286-42be-a38e-1c377a86976b.png) +1. Fill out the form with anything it will accept as valid. Most fields can be gibberish, as the application is not supposed to ever move past the "testing" stage and is for your personal use only. +In the "Whitelist Users" field, input the username of a Twitch account you control. While this feature is *technically* limited to Twitch, you can use the alerts overlay for donations on YouTube and Facebook as well. +For now, simply set the "Redirect URI" to `http://localhost`, you will change this soon. +Then, hit create: +![image](https://user-images.githubusercontent.com/28876473/127759264-ae91539a-5694-4096-a478-80eb02b7b594.png) +1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Watch Only (to accept on-chain donations) extenions: +![image](https://user-images.githubusercontent.com/28876473/127759486-0e3420c2-c498-4bf9-932e-0abfa17bd478.png) +1. Create a "NEW SERVICE" using the button. Fill in all the information (you get your Client ID and Secret from the Streamlabs App page): +![image](https://user-images.githubusercontent.com/28876473/127759512-8e8b4e90-2a64-422a-bf0a-5508d0630bed.png) +![image](https://user-images.githubusercontent.com/28876473/127759526-7f2a4980-39ea-4e58-8af0-c9fb381e5524.png) +1. Right-click and copy the "Redirect URI for Streamlabs" link (you might have to refresh the page for the text to turn into a link) and input it into the "Redirect URI" field for your Streamelements App, and hit "Save Settings": +![image](https://user-images.githubusercontent.com/28876473/127759570-52d34c07-6857-467b-bcb3-54e10679aedb.png) +![image](https://user-images.githubusercontent.com/28876473/127759604-b3c8270b-bd02-44df-a525-9d85af337d14.png) +1. You can now authenticate your app on LNbits by clicking on this button and following the instructions. Make sure to log in with the Twitch account you entered in the "Whitelist Users" field: +![image](https://user-images.githubusercontent.com/28876473/127759642-a3787a6a-3cab-4c44-a2d4-ab45fbbe3fab.png) +![image](https://user-images.githubusercontent.com/28876473/127759681-7289e7f6-0ff1-4988-944f-484040f6b9c7.png) +If everything worked correctly, you should now be redirected back to LNbits. When scrolling all the way right, you should now see that the service has been authenticated: +![image](https://user-images.githubusercontent.com/28876473/127759715-7e839261-d505-4e07-a0e4-f347f114149f.png) +You can now share the link to your donations page, which you can get here: +![image](https://user-images.githubusercontent.com/28876473/127759730-8dd11e61-0186-4935-b1ed-b66d35b05043.png) +![image](https://user-images.githubusercontent.com/28876473/127759747-67d3033f-6ef1-4033-b9b1-51b87189ff8b.png) +Of course, this has to be available publicly on the internet (or, depending on your viewers' technical ability, over Tor). +When your viewers donate to you, these donations will now show up in your Streamlabs donation feed, as well as your alerts overlay (if it is configured to include donations). +

CONGRATS! Let the sats flow!

diff --git a/lnbits/extensions/streamalerts/__init__.py b/lnbits/extensions/streamalerts/__init__.py new file mode 100644 index 00000000..72f0ae7c --- /dev/null +++ b/lnbits/extensions/streamalerts/__init__.py @@ -0,0 +1,11 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_streamalerts") + +streamalerts_ext: Blueprint = Blueprint( + "streamalerts", __name__, static_folder="static", template_folder="templates" +) + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/streamalerts/config.json b/lnbits/extensions/streamalerts/config.json new file mode 100644 index 00000000..2fbcc55e --- /dev/null +++ b/lnbits/extensions/streamalerts/config.json @@ -0,0 +1,6 @@ +{ + "name": "Stream Alerts", + "short_description": "Integrate Bitcoin donations into your stream alerts!", + "icon": "notifications_active", + "contributors": ["Fittiboy"] +} diff --git a/lnbits/extensions/streamalerts/crud.py b/lnbits/extensions/streamalerts/crud.py new file mode 100644 index 00000000..902b74df --- /dev/null +++ b/lnbits/extensions/streamalerts/crud.py @@ -0,0 +1,261 @@ +from . import db +from .models import Donation, Service + +from ..satspay.crud import delete_charge + +import httpx + +from http import HTTPStatus +from quart import jsonify + +from typing import Optional + +from lnbits.helpers import urlsafe_short_hash +from lnbits.core.crud import get_wallet + + +async def get_service_redirect_uri(request, service_id): + """Return the service's redirect URI, to be given to the third party API""" + uri_base = request.scheme + "://" + uri_base += request.headers["Host"] + "/streamalerts/api/v1" + redirect_uri = uri_base + f"/authenticate/{service_id}" + return redirect_uri + + +async def get_charge_details(service_id): + """Return the default details for a satspay charge + + These might be different depending for services implemented in the future. + """ + details = { + "time": 1440, + } + service = await get_service(service_id) + wallet_id = service.wallet + wallet = await get_wallet(wallet_id) + user = wallet.user + details["user"] = user + details["lnbitswallet"] = wallet_id + details["onchainwallet"] = service.onchain + return details + + +async def create_donation( + id: str, + wallet: str, + cur_code: str, + sats: int, + amount: float, + service: int, + name: str = "Anonymous", + message: str = "", + posted: bool = False, +) -> Donation: + """Create a new Donation""" + await db.execute( + """ + INSERT INTO Donations ( + id, + wallet, + name, + message, + cur_code, + sats, + amount, + service, + posted + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (id, wallet, name, message, cur_code, sats, amount, service, posted), + ) + return await get_donation(id) + + +async def post_donation(donation_id: str) -> tuple: + """Post donations to their respective third party APIs + + If the donation has already been posted, it will not be posted again. + """ + donation = await get_donation(donation_id) + if not donation: + return (jsonify({"message": "Donation not found!"}), HTTPStatus.BAD_REQUEST) + if donation.posted: + return ( + jsonify({"message": "Donation has already been posted!"}), + HTTPStatus.BAD_REQUEST, + ) + service = await get_service(donation.service) + if service.servicename == "Streamlabs": + url = "https://streamlabs.com/api/v1.0/donations" + data = { + "name": donation.name, + "message": donation.message, + "identifier": "LNbits", + "amount": donation.amount, + "currency": donation.cur_code.upper(), + "access_token": service.token, + } + async with httpx.AsyncClient() as client: + response = await client.post(url, data=data) + print(response.json()) + status = [s for s in list(HTTPStatus) if s == response.status_code][0] + elif service.servicename == "StreamElements": + return ( + jsonify({"message": "StreamElements not yet supported!"}), + HTTPStatus.BAD_REQUEST, + ) + else: + return (jsonify({"message": "Unsopported servicename"}), HTTPStatus.BAD_REQUEST) + await db.execute("UPDATE Donations SET posted = 1 WHERE id = ?", (donation_id,)) + return (jsonify(response.json()), status) + + +async def create_service( + twitchuser: str, + client_id: str, + client_secret: str, + wallet: str, + servicename: str, + state: str = None, + onchain: str = None, +) -> Service: + """Create a new Service""" + result = await db.execute( + """ + INSERT INTO Services ( + twitchuser, + client_id, + client_secret, + wallet, + servicename, + authenticated, + state, + onchain + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + twitchuser, + client_id, + client_secret, + wallet, + servicename, + False, + urlsafe_short_hash(), + onchain, + ), + ) + service_id = result._result_proxy.lastrowid + service = await get_service(service_id) + return service + + +async def get_service(service_id: int, by_state: str = None) -> Optional[Service]: + """Return a service either by ID or, available, by state + + Each Service's donation page is reached through its "state" hash + instead of the ID, preventing accidental payments to the wrong + streamer via typos like 2 -> 3. + """ + if by_state: + row = await db.fetchone("SELECT * FROM Services WHERE state = ?", (by_state,)) + else: + row = await db.fetchone("SELECT * FROM Services WHERE id = ?", (service_id,)) + return Service.from_row(row) if row else None + + +async def get_services(wallet_id: str) -> Optional[list]: + """Return all services belonging assigned to the wallet_id""" + rows = await db.fetchall("SELECT * FROM Services WHERE wallet = ?", (wallet_id,)) + return [Service.from_row(row) for row in rows] if rows else None + + +async def authenticate_service(service_id, code, redirect_uri): + """Use authentication code from third party API to retreive access token""" + # The API token is passed in the querystring as 'code' + service = await get_service(service_id) + wallet = await get_wallet(service.wallet) + user = wallet.user + url = "https://streamlabs.com/api/v1.0/token" + data = { + "grant_type": "authorization_code", + "code": code, + "client_id": service.client_id, + "client_secret": service.client_secret, + "redirect_uri": redirect_uri, + } + print(data) + async with httpx.AsyncClient() as client: + response = (await client.post(url, data=data)).json() + print(response) + token = response["access_token"] + success = await service_add_token(service_id, token) + return f"/streamalerts/?usr={user}", success + + +async def service_add_token(service_id, token): + """Add access token to its corresponding Service + + This also sets authenticated = 1 to make sure the token + is not overwritten. + Tokens for Streamlabs never need to be refreshed. + """ + if (await get_service(service_id)).authenticated: + return False + await db.execute( + "UPDATE Services SET authenticated = 1, token = ? where id = ?", + ( + token, + service_id, + ), + ) + return True + + +async def delete_service(service_id: int) -> None: + """Delete a Service and all corresponding Donations""" + await db.execute("DELETE FROM Services WHERE id = ?", (service_id,)) + rows = await db.fetchall("SELECT * FROM Donations WHERE service = ?", (service_id,)) + for row in rows: + await delete_donation(row["id"]) + + +async def get_donation(donation_id: str) -> Optional[Donation]: + """Return a Donation""" + row = await db.fetchone("SELECT * FROM Donations WHERE id = ?", (donation_id,)) + return Donation.from_row(row) if row else None + + +async def get_donations(wallet_id: str) -> Optional[list]: + """Return all Donations assigned to wallet_id""" + rows = await db.fetchall("SELECT * FROM Donations WHERE wallet = ?", (wallet_id,)) + return [Donation.from_row(row) for row in rows] if rows else None + + +async def delete_donation(donation_id: str) -> None: + """Delete a Donation and its corresponding statspay charge""" + await db.execute("DELETE FROM Donations WHERE id = ?", (donation_id,)) + await delete_charge(donation_id) + + +async def update_donation(donation_id: str, **kwargs) -> Donation: + """Update a Donation""" + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE Donations SET {q} WHERE id = ?", (*kwargs.values(), donation_id) + ) + row = await db.fetchone("SELECT * FROM Donations WHERE id = ?", (donation_id,)) + assert row, "Newly updated donation couldn't be retrieved" + return Donation(**row) + + +async def update_service(service_id: str, **kwargs) -> Donation: + """Update a service""" + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE Services SET {q} WHERE id = ?", (*kwargs.values(), service_id) + ) + row = await db.fetchone("SELECT * FROM Services WHERE id = ?", (service_id,)) + assert row, "Newly updated service couldn't be retrieved" + return Service(**row) diff --git a/lnbits/extensions/streamalerts/migrations.py b/lnbits/extensions/streamalerts/migrations.py new file mode 100644 index 00000000..64d75a8d --- /dev/null +++ b/lnbits/extensions/streamalerts/migrations.py @@ -0,0 +1,35 @@ +async def m001_initial(db): + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS Services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + state TEXT NOT NULL, + twitchuser TEXT NOT NULL, + client_id TEXT NOT NULL, + client_secret TEXT NOT NULL, + wallet TEXT NOT NULL, + onchain TEXT, + servicename TEXT NOT NULL, + authenticated BOOLEAN NOT NULL, + token TEXT + ); + """ + ) + + await db.execute( + """ + CREATE TABLE IF NOT EXISTS Donations ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL, + message TEXT NOT NULL, + cur_code TEXT NOT NULL, + sats INT NOT NULL, + amount FLOAT NOT NULL, + service INTEGER NOT NULL, + posted BOOLEAN NOT NULL, + FOREIGN KEY(service) REFERENCES Services(id) + ); + """ + ) diff --git a/lnbits/extensions/streamalerts/models.py b/lnbits/extensions/streamalerts/models.py new file mode 100644 index 00000000..20923ebb --- /dev/null +++ b/lnbits/extensions/streamalerts/models.py @@ -0,0 +1,44 @@ +from sqlite3 import Row +from typing import NamedTuple, Optional + + +class Donation(NamedTuple): + """A Donation simply contains all the necessary information about a + user's donation to a streamer + """ + + id: str # This ID always corresponds to a satspay charge ID + wallet: str + name: str # Name of the donor + message: str # Donation message + cur_code: str # Three letter currency code accepted by Streamlabs + sats: int + amount: float # The donation amount after fiat conversion + service: int # The ID of the corresponding Service + posted: bool # Whether the donation has already been posted to a Service + + @classmethod + def from_row(cls, row: Row) -> "Donation": + return cls(**dict(row)) + + +class Service(NamedTuple): + """A Service represents an integration with a third-party API + + Currently, Streamlabs is the only supported Service. + """ + + id: int + state: str # A random hash used during authentication + twitchuser: str # The Twitch streamer's username + client_id: str # Third party service Client ID + client_secret: str # Secret corresponding to the Client ID + wallet: str + onchain: str + servicename: str # Currently, this will just always be "Streamlabs" + authenticated: bool # Whether a token (see below) has been acquired yet + token: Optional[int] # The token with which to authenticate requests + + @classmethod + def from_row(cls, row: Row) -> "Service": + return cls(**dict(row)) diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html b/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html new file mode 100644 index 00000000..33b52f15 --- /dev/null +++ b/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html @@ -0,0 +1,18 @@ + + +

+ Stream Alerts: Integrate Bitcoin into your stream alerts! +

+

+ Accept Bitcoin donations on Twitch, and integrate them into your alerts. + Present your viewers with a simple donation page, and add those donations + to Streamlabs to play alerts on your stream!
+ For detailed setup instructions, check out + this guide!
+ + Created by, Fitti +

+
+
diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/display.html b/lnbits/extensions/streamalerts/templates/streamalerts/display.html new file mode 100644 index 00000000..34a2e530 --- /dev/null +++ b/lnbits/extensions/streamalerts/templates/streamalerts/display.html @@ -0,0 +1,94 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
Donate Bitcoin to {{ twitchuser }}
+
+ + + + +
+ Submit +
+
+
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/index.html b/lnbits/extensions/streamalerts/templates/streamalerts/index.html new file mode 100644 index 00000000..2349b941 --- /dev/null +++ b/lnbits/extensions/streamalerts/templates/streamalerts/index.html @@ -0,0 +1,506 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Service + + + + + +
+
+
Services
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Donations
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
LNbits Stream Alerts extension
+
+ + + {% include "streamalerts/_api_docs.html" %} + +
+
+ + + + + + +
+
+
+ +
+
+ + + Watch-Only extension MUST be activated and have a wallet + + +
+
+
+
+ +
+ + + + +
+ Update Service + + Create Service + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/streamalerts/views.py b/lnbits/extensions/streamalerts/views.py new file mode 100644 index 00000000..3e9e771d --- /dev/null +++ b/lnbits/extensions/streamalerts/views.py @@ -0,0 +1,28 @@ +from quart import g, abort, render_template + +from lnbits.decorators import check_user_exists, validate_uuids +from http import HTTPStatus + +from . import streamalerts_ext +from .crud import get_service + + +@streamalerts_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + """Return the extension's settings page""" + return await render_template("streamalerts/index.html", user=g.user) + + +@streamalerts_ext.route("/") +async def donation(state): + """Return the donation form for the Service corresponding to state""" + service = await get_service(0, by_state=state) + if not service: + abort(HTTPStatus.NOT_FOUND, "Service does not exist.") + return await render_template( + "streamalerts/display.html", + twitchuser=service.twitchuser, + service=service.id + ) diff --git a/lnbits/extensions/streamalerts/views_api.py b/lnbits/extensions/streamalerts/views_api.py new file mode 100644 index 00000000..0ff70717 --- /dev/null +++ b/lnbits/extensions/streamalerts/views_api.py @@ -0,0 +1,271 @@ +from quart import g, redirect, request, jsonify +from http import HTTPStatus + +from lnbits.decorators import api_validate_post_request, api_check_wallet_key +from lnbits.core.crud import get_wallet, get_user +from lnbits.utils.exchange_rates import btc_price + +from . import streamalerts_ext +from .crud import ( + get_charge_details, + get_service_redirect_uri, + create_donation, + post_donation, + get_donation, + get_donations, + delete_donation, + create_service, + get_service, + get_services, + authenticate_service, + update_donation, + update_service, + delete_service, +) +from ..satspay.crud import create_charge, get_charge + + +@streamalerts_ext.route("/api/v1/services", methods=["POST"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "twitchuser": {"type": "string", "required": True}, + "client_id": {"type": "string", "required": True}, + "client_secret": {"type": "string", "required": True}, + "wallet": {"type": "string", "required": True}, + "servicename": {"type": "string", "required": True}, + "onchain": {"type": "string"}, + } +) +async def api_create_service(): + """Create a service, which holds data about how/where to post donations""" + service = await create_service(**g.data) + wallet = await get_wallet(service.wallet) + user = wallet.user + redirect_url = request.scheme + "://" + request.headers["Host"] + redirect_url += f"/streamalerts/?usr={user}&created={str(service.id)}" + return redirect(redirect_url) + + +@streamalerts_ext.route("/api/v1/getaccess/", methods=["GET"]) +async def api_get_access(service_id): + """Redirect to Streamlabs' Approve/Decline page for API access for Service + with service_id + """ + service = await get_service(service_id) + if service: + redirect_uri = await get_service_redirect_uri(request, service_id) + params = { + "response_type": "code", + "client_id": service.client_id, + "redirect_uri": redirect_uri, + "scope": "donations.create", + "state": service.state, + } + endpoint_url = "https://streamlabs.com/api/v1.0/authorize/?" + querystring = "&".join([f"{key}={value}" for key, value in params.items()]) + redirect_url = endpoint_url + querystring + return redirect(redirect_url) + else: + return (jsonify({"message": "Service does not exist!"}), HTTPStatus.BAD_REQUEST) + + +@streamalerts_ext.route("/api/v1/authenticate/", methods=["GET"]) +async def api_authenticate_service(service_id): + """Endpoint visited via redirect during third party API authentication + + If successful, an API access token will be added to the service, and + the user will be redirected to index.html. + """ + code = request.args.get("code") + state = request.args.get("state") + service = await get_service(service_id) + if service.state != state: + return (jsonify({"message": "State doesn't match!"}), HTTPStatus.BAD_Request) + redirect_uri = request.scheme + "://" + request.headers["Host"] + redirect_uri += f"/streamalerts/api/v1/authenticate/{service_id}" + url, success = await authenticate_service(service_id, code, redirect_uri) + if success: + return redirect(url) + else: + return ( + jsonify({"message": "Service already authenticated!"}), + HTTPStatus.BAD_REQUEST, + ) + + +@streamalerts_ext.route("/api/v1/donations", methods=["POST"]) +@api_validate_post_request( + schema={ + "name": {"type": "string"}, + "sats": {"type": "integer", "required": True}, + "service": {"type": "integer", "required": True}, + "message": {"type": "string"}, + } +) +async def api_create_donation(): + """Take data from donation form and return satspay charge""" + # Currency is hardcoded while frotnend is limited + cur_code = "USD" + sats = g.data["sats"] + message = g.data.get("message", "") + # Fiat amount is calculated here while frontend is limited + price = await btc_price(cur_code) + amount = sats * (10 ** (-8)) * price + webhook_base = request.scheme + "://" + request.headers["Host"] + service_id = g.data["service"] + service = await get_service(service_id) + charge_details = await get_charge_details(service.id) + name = g.data.get("name", "Anonymous") + description = f"{sats} sats donation from {name} to {service.twitchuser}" + charge = await create_charge( + amount=sats, + completelink=f"https://twitch.tv/{service.twitchuser}", + completelinktext="Back to Stream!", + webhook=webhook_base + "/streamalerts/api/v1/postdonation", + description=description, + **charge_details, + ) + await create_donation( + id=charge.id, + wallet=service.wallet, + message=message, + name=name, + cur_code=cur_code, + sats=g.data["sats"], + amount=amount, + service=g.data["service"], + ) + return (jsonify({"redirect_url": f"/satspay/{charge.id}"}), HTTPStatus.OK) + + +@streamalerts_ext.route("/api/v1/postdonation", methods=["POST"]) +@api_validate_post_request( + schema={ + "id": {"type": "string", "required": True}, + } +) +async def api_post_donation(): + """Post a paid donation to Stremalabs/StreamElements. + + This endpoint acts as a webhook for the SatsPayServer extension.""" + data = await request.get_json(force=True) + donation_id = data.get("id", "No ID") + charge = await get_charge(donation_id) + if charge and charge.paid: + return await post_donation(donation_id) + else: + return (jsonify({"message": "Not a paid charge!"}), HTTPStatus.BAD_REQUEST) + + +@streamalerts_ext.route("/api/v1/services", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_get_services(): + """Return list of all services assigned to wallet with given invoice key""" + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + services = [] + for wallet_id in wallet_ids: + new_services = await get_services(wallet_id) + services += new_services if new_services else [] + return ( + jsonify([service._asdict() for service in services] if services else []), + HTTPStatus.OK, + ) + + +@streamalerts_ext.route("/api/v1/donations", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_get_donations(): + """Return list of all donations assigned to wallet with given invoice + key + """ + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + donations = [] + for wallet_id in wallet_ids: + new_donations = await get_donations(wallet_id) + donations += new_donations if new_donations else [] + return ( + jsonify([donation._asdict() for donation in donations] if donations else []), + HTTPStatus.OK, + ) + + +@streamalerts_ext.route("/api/v1/donations/", methods=["PUT"]) +@api_check_wallet_key("invoice") +async def api_update_donation(donation_id=None): + """Update a donation with the data given in the request""" + if donation_id: + donation = await get_donation(donation_id) + + if not donation: + return ( + jsonify({"message": "Donation does not exist."}), + HTTPStatus.NOT_FOUND, + ) + + if donation.wallet != g.wallet.id: + return (jsonify({"message": "Not your donation."}), HTTPStatus.FORBIDDEN) + + donation = await update_donation(donation_id, **g.data) + else: + return ( + jsonify({"message": "No donation ID specified"}), + HTTPStatus.BAD_REQUEST, + ) + return jsonify(donation._asdict()), HTTPStatus.CREATED + + +@streamalerts_ext.route("/api/v1/services/", methods=["PUT"]) +@api_check_wallet_key("invoice") +async def api_update_service(service_id=None): + """Update a service with the data given in the request""" + if service_id: + service = await get_service(service_id) + + if not service: + return ( + jsonify({"message": "Service does not exist."}), + HTTPStatus.NOT_FOUND, + ) + + if service.wallet != g.wallet.id: + return (jsonify({"message": "Not your service."}), HTTPStatus.FORBIDDEN) + + service = await update_service(service_id, **g.data) + else: + return (jsonify({"message": "No service ID specified"}), HTTPStatus.BAD_REQUEST) + return jsonify(service._asdict()), HTTPStatus.CREATED + + +@streamalerts_ext.route("/api/v1/donations/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_delete_donation(donation_id): + """Delete the donation with the given donation_id""" + donation = await get_donation(donation_id) + if not donation: + return (jsonify({"message": "No donation with this ID!"}), HTTPStatus.NOT_FOUND) + if donation.wallet != g.wallet.id: + return ( + jsonify({"message": "Not authorized to delete this donation!"}), + HTTPStatus.FORBIDDEN, + ) + await delete_donation(donation_id) + + return "", HTTPStatus.NO_CONTENT + + +@streamalerts_ext.route("/api/v1/services/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_delete_service(service_id): + """Delete the service with the given service_id""" + service = await get_service(service_id) + if not service: + return (jsonify({"message": "No service with this ID!"}), HTTPStatus.NOT_FOUND) + if service.wallet != g.wallet.id: + return ( + jsonify({"message": "Not authorized to delete this service!"}), + HTTPStatus.FORBIDDEN, + ) + await delete_service(service_id) + + return "", HTTPStatus.NO_CONTENT