mirror of
https://github.com/cryptoadvance/specter-desktop.git
synced 2026-08-13 12:33:29 +02:00
A feature for a notification system has been added, including websockets and SSL support, improved error handling, and better logging. There have been various bug fixes, refactoring, and documentation updates throughout the process. The feature is now complete and includes new functionality for managing and displaying notifications, as well as testing and debugging tools.m> Co-authored-by: relativisticelectron <60378539+relativisticelectron@users.noreply.github.com>
This commit is contained in:
parent
c087fbcf71
commit
99f5404705
39 changed files with 2868 additions and 61 deletions
|
|
@ -2,7 +2,7 @@ certifi==2022.12.7
|
|||
chardet==3.0.4
|
||||
Click==8.1.1
|
||||
Flask==2.1.1
|
||||
Werkzeug==2.0.0 # need to pin because Flask doesn't work with newer ones
|
||||
Werkzeug==2.0.3 # need to pin because Flask doesn't work with newer ones
|
||||
Flask-Babel==2.0.0
|
||||
Flask-Cors==3.0.10
|
||||
Flask-Login==0.5.0
|
||||
|
|
@ -25,6 +25,7 @@ mnemonic==0.20
|
|||
cryptography==3.4.7
|
||||
Flask-APScheduler==1.12.4
|
||||
gunicorn==20.1.0
|
||||
simple-websocket==0.8.1
|
||||
protobuf==3.20.2
|
||||
PyJWT==2.4.0
|
||||
pytimeparse==1.1.8
|
||||
|
|
@ -34,4 +35,4 @@ cryptoadvance-liquidissuer==0.2.4
|
|||
specterext-exfund==0.1.7
|
||||
specterext-faucet==0.1.2
|
||||
cryptoadvance.spectrum==0.3.1
|
||||
specterext-stacktrack==0.2.1
|
||||
specterext-stacktrack==0.2.1
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#
|
||||
# This file is autogenerated by pip-compile
|
||||
# This file is autogenerated by pip-compile with python 3.10
|
||||
# To update, run:
|
||||
#
|
||||
# pip-compile --generate-hashes requirements.in
|
||||
|
|
@ -12,9 +12,9 @@ apscheduler==3.9.1 \
|
|||
--hash=sha256:65e6574b6395498d371d045f2a8a7e4f7d50c6ad21ef7313d15b1c7cf20df1e3 \
|
||||
--hash=sha256:ddc25a0ddd899de44d7f451f4375fb971887e65af51e41e5dcf681f59b8b2c9a
|
||||
# via flask-apscheduler
|
||||
babel==2.10.3 \
|
||||
--hash=sha256:7614553711ee97490f732126dc077f8d0ae084ebc6a96e23db1482afabdb2c51 \
|
||||
--hash=sha256:ff56f4892c1c4bf0d814575ea23471c230d544203c7748e8c68f0089478d48eb
|
||||
babel==2.11.0 \
|
||||
--hash=sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe \
|
||||
--hash=sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6
|
||||
# via flask-babel
|
||||
base58==2.1.1 \
|
||||
--hash=sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2 \
|
||||
|
|
@ -119,7 +119,7 @@ cryptoadvance-liquidissuer==0.2.4 \
|
|||
--hash=sha256:5a2c531801854c5a4a46daf184877e22f731cdb42d2cfb840785bda7371ba6fb \
|
||||
--hash=sha256:9e468f3e35ecc566b3f74a2263677cf26632548abb194521dba15ad37acd1e9b
|
||||
# via -r requirements.in
|
||||
cryptoadvance.spectrum==0.3.1 \
|
||||
cryptoadvance-spectrum==0.3.1 \
|
||||
--hash=sha256:c0eedce7d88f28a1fbf3a5afbcf3b03f1effdb0983f07445953a1d9fbe789209 \
|
||||
--hash=sha256:c8eba63d3aec6cc391784e5dde99ec39b13f719a062ef1ce786e5fdf850150d8
|
||||
# via -r requirements.in
|
||||
|
|
@ -153,7 +153,21 @@ embit==0.6.1 \
|
|||
--hash=sha256:16a84c6668dc9ffc907594457a46f7142cee379646bc009a5a9b77b0d2cb4e12
|
||||
# via
|
||||
# -r requirements.in
|
||||
# cryptoadvance.spectrum
|
||||
# cryptoadvance-spectrum
|
||||
flask==2.1.1 \
|
||||
--hash=sha256:8a4cf32d904cf5621db9f0c9fbcd7efabf3003f22a04e4d0ce790c7137ec5264 \
|
||||
--hash=sha256:a8c9bd3e558ec99646d177a9739c41df1ded0629480b4c8d2975412f3c9519c8
|
||||
# via
|
||||
# -r requirements.in
|
||||
# cryptoadvance-spectrum
|
||||
# flask-apscheduler
|
||||
# flask-babel
|
||||
# flask-cors
|
||||
# flask-httpauth
|
||||
# flask-login
|
||||
# flask-restful
|
||||
# flask-sqlalchemy
|
||||
# flask-wtf
|
||||
flask-apscheduler==1.12.4 \
|
||||
--hash=sha256:681dae34dc6cc9403ce674795e53abd0bff540472129cfd3d3c93e0e1d502da8
|
||||
# via -r requirements.in
|
||||
|
|
@ -180,22 +194,8 @@ flask-restful==0.3.9 \
|
|||
flask-sqlalchemy==2.5.1 \
|
||||
--hash=sha256:2bda44b43e7cacb15d4e05ff3cc1f8bc97936cc464623424102bfc2c35e95912 \
|
||||
--hash=sha256:f12c3d4cc5cc7fdcc148b9527ea05671718c3ea45d50c7e732cceb33f574b390
|
||||
# via cryptoadvance.spectrum
|
||||
flask==2.1.1 \
|
||||
--hash=sha256:8a4cf32d904cf5621db9f0c9fbcd7efabf3003f22a04e4d0ce790c7137ec5264 \
|
||||
--hash=sha256:a8c9bd3e558ec99646d177a9739c41df1ded0629480b4c8d2975412f3c9519c8
|
||||
# via
|
||||
# -r requirements.in
|
||||
# cryptoadvance.spectrum
|
||||
# flask-apscheduler
|
||||
# flask-babel
|
||||
# flask-cors
|
||||
# flask-httpauth
|
||||
# flask-login
|
||||
# flask-restful
|
||||
# flask-sqlalchemy
|
||||
# flask-wtf
|
||||
flask_wtf==0.15.1 \
|
||||
# via cryptoadvance-spectrum
|
||||
flask-wtf==0.15.1 \
|
||||
--hash=sha256:6ff7af73458f182180906a37a783e290bdc8a3817fe4ad17227563137ca285bf \
|
||||
--hash=sha256:ff177185f891302dc253437fe63081e7a46a4e99aca61dfe086fb23e54fff2dc
|
||||
# via -r requirements.in
|
||||
|
|
@ -271,6 +271,10 @@ gunicorn==20.1.0 \
|
|||
--hash=sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e \
|
||||
--hash=sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8
|
||||
# via -r requirements.in
|
||||
h11==0.14.0 \
|
||||
--hash=sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d \
|
||||
--hash=sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761
|
||||
# via wsproto
|
||||
hidapi==0.12.0.post2 \
|
||||
--hash=sha256:00bad74617622b7b7abbb24eae5c11bd91957bb87217d62d8feea15c971a77b5 \
|
||||
--hash=sha256:0292a29325b905fbfbb39cc368055cda63339fed684eceb7d4d241acc5e60d50 \
|
||||
|
|
@ -317,7 +321,7 @@ idna==3.4 \
|
|||
--hash=sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4 \
|
||||
--hash=sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2
|
||||
# via requests
|
||||
importlib_metadata==4.8.1 \
|
||||
importlib-metadata==4.8.1 \
|
||||
--hash=sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15 \
|
||||
--hash=sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1
|
||||
# via -r requirements.in
|
||||
|
|
@ -594,7 +598,7 @@ psycopg2-binary==2.9.5 \
|
|||
--hash=sha256:f95b8aca2703d6a30249f83f4fe6a9abf2e627aa892a5caaab2267d56be7ab69
|
||||
# via
|
||||
# -r requirements.in
|
||||
# cryptoadvance.spectrum
|
||||
# cryptoadvance-spectrum
|
||||
pyaes==1.6.1 \
|
||||
--hash=sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f
|
||||
# via hwi
|
||||
|
|
@ -637,29 +641,33 @@ pytimeparse==1.1.8 \
|
|||
--hash=sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd \
|
||||
--hash=sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a
|
||||
# via -r requirements.in
|
||||
pytz-deprecation-shim==0.1.0.post0 \
|
||||
--hash=sha256:8314c9692a636c8eb3bda879b9f119e350e93223ae83e70e80c31675a0fdc1a6 \
|
||||
--hash=sha256:af097bae1b616dde5c5744441e2ddc69e74dfdcb0c263129610d85b87445a59d
|
||||
# via tzlocal
|
||||
pytz==2022.5 \
|
||||
--hash=sha256:335ab46900b1465e714b4fda4963d87363264eb662aab5e65da039c25f1f5b22 \
|
||||
--hash=sha256:c4d88f472f54d615e9cd582a5004d1e5f624854a6a27a6211591c251f22a6914
|
||||
pytz==2022.7.1 \
|
||||
--hash=sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0 \
|
||||
--hash=sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a
|
||||
# via
|
||||
# apscheduler
|
||||
# babel
|
||||
# flask-babel
|
||||
# flask-restful
|
||||
# pandas
|
||||
pytz-deprecation-shim==0.1.0.post0 \
|
||||
--hash=sha256:8314c9692a636c8eb3bda879b9f119e350e93223ae83e70e80c31675a0fdc1a6 \
|
||||
--hash=sha256:af097bae1b616dde5c5744441e2ddc69e74dfdcb0c263129610d85b87445a59d
|
||||
# via tzlocal
|
||||
requests==2.26.0 \
|
||||
--hash=sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24 \
|
||||
--hash=sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7
|
||||
# via
|
||||
# -r requirements.in
|
||||
# cryptoadvance.spectrum
|
||||
# cryptoadvance-spectrum
|
||||
semver==2.13.0 \
|
||||
--hash=sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4 \
|
||||
--hash=sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f
|
||||
# via bitbox02
|
||||
simple-websocket==0.8.1 \
|
||||
--hash=sha256:0abe874f6a0c6ddd197dbd4f0ce5708b47610a0d14fa2b0762e515659573d44a \
|
||||
--hash=sha256:bab2f34151d8b9abb1ea6d911e5edc8232fc203b6f814d179db0ba3f93e4e026
|
||||
# via -r requirements.in
|
||||
six==1.16.0 \
|
||||
--hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \
|
||||
--hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254
|
||||
|
|
@ -751,12 +759,16 @@ urllib3==1.26.12 \
|
|||
--hash=sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e \
|
||||
--hash=sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997
|
||||
# via requests
|
||||
werkzeug==2.0.0 \
|
||||
--hash=sha256:3389bbfe6d40c6dd25e6d3f974155163c8b3de5bbda6a89342d4ab93fae80ba0 \
|
||||
--hash=sha256:64c02f6495ba01eddd6625b3675f357cd358a73f1e38458a56ad86c5baa30b53
|
||||
werkzeug==2.0.3 \
|
||||
--hash=sha256:1421ebfc7648a39a5c58c601b154165d05cf47a3cd0ccb70857cbdacf6c8f2b8 \
|
||||
--hash=sha256:b863f8ff057c522164b6067c9e28b041161b4be5ba4d0daceeaa50a163822d3c
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask
|
||||
wsproto==1.2.0 \
|
||||
--hash=sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065 \
|
||||
--hash=sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736
|
||||
# via simple-websocket
|
||||
wtforms==3.0.1 \
|
||||
--hash=sha256:6b351bbb12dd58af57ffef05bc78425d08d1914e0fd68ee14143b7ade023c5bc \
|
||||
--hash=sha256:837f2f0e0ca79481b92884962b914eba4e72b7a2daaf1f939c890ed0124b834b
|
||||
|
|
|
|||
|
|
@ -125,6 +125,29 @@ def server(
|
|||
if key:
|
||||
app.config["KEY"] = key
|
||||
|
||||
# the app.config needs to be configured before init_app, such that the service callbacks
|
||||
# like after_serverpy_init_app have this information available
|
||||
if host != app.config["HOST"]:
|
||||
app.config["HOST"] = host
|
||||
|
||||
# set up kwargs dict for app.run
|
||||
kwargs = {
|
||||
"host": host,
|
||||
"port": app.config["PORT"],
|
||||
}
|
||||
# watch templates folder to reload when something changes
|
||||
extra_dirs = ["templates"]
|
||||
extra_files = extra_dirs[:]
|
||||
for extra_dir in extra_dirs:
|
||||
for dirname, dirs, files in os.walk(extra_dir):
|
||||
for filename in files:
|
||||
filename = os.path.join(dirname, filename)
|
||||
if os.path.isfile(filename):
|
||||
extra_files.append(filename)
|
||||
kwargs["extra_files"] = extra_files
|
||||
|
||||
kwargs = configure_ssl(kwargs, app.config, ssl)
|
||||
|
||||
app.app_context().push()
|
||||
init_app(app, hwibridge=hwibridge)
|
||||
|
||||
|
|
@ -140,19 +163,6 @@ def server(
|
|||
|
||||
toraddr_file = path.join(app.specter.data_folder, "onion.txt")
|
||||
|
||||
# watch templates folder to reload when something changes
|
||||
extra_dirs = ["templates"]
|
||||
extra_files = extra_dirs[:]
|
||||
for extra_dir in extra_dirs:
|
||||
for dirname, dirs, files in os.walk(extra_dir):
|
||||
for filename in files:
|
||||
filename = os.path.join(dirname, filename)
|
||||
if os.path.isfile(filename):
|
||||
extra_files.append(filename)
|
||||
|
||||
kwargs = {"host": host, "port": app.config["PORT"], "extra_files": extra_files}
|
||||
kwargs = configure_ssl(kwargs, app.config, ssl)
|
||||
|
||||
if hwibridge:
|
||||
if kwargs.get("ssl_context"):
|
||||
logger.error(
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ class BaseConfig(object):
|
|||
# The prefix for extensions which don't get access to the session cookie (if SPECTER_URL_PREFIX isn't compromised)
|
||||
ISOLATED_CLIENT_EXT_URL_PREFIX = "/ext"
|
||||
|
||||
HOST = os.getenv("HOST", "127.0.0.1")
|
||||
PORT = os.getenv("PORT", 25441)
|
||||
CONNECT_TOR = _get_bool_env_var(os.getenv("CONNECT_TOR", "False"))
|
||||
SPECTER_DATA_FOLDER = os.path.expanduser(
|
||||
|
|
@ -183,6 +184,7 @@ class BaseConfig(object):
|
|||
"cryptoadvance.specterext.swan.service",
|
||||
"cryptoadvance.specterext.liquidissuer.service",
|
||||
"cryptoadvance.specterext.devhelp.service",
|
||||
"cryptoadvance.specterext.notifications.service",
|
||||
"cryptoadvance.specterext.exfund.service",
|
||||
"cryptoadvance.specterext.faucet.service",
|
||||
"cryptoadvance.specterext.electrum.service",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from flask.blueprints import Blueprint
|
|||
|
||||
from cryptoadvance.specter.util.specter_migrator import SpecterMigration
|
||||
|
||||
from ...services.service import Service
|
||||
from ...services.service import Service, ServiceOptionality
|
||||
from ...services import callbacks, ExtensionException
|
||||
from ...util.reflection import (
|
||||
_get_module_from_class,
|
||||
|
|
@ -395,6 +395,16 @@ class ServiceManager:
|
|||
self.specter.service_unencrypted_storage_manager.delete_all_service_data(user)
|
||||
logger.debug(f"Deleted unencrypted services")
|
||||
|
||||
def add_required_services_to_users(self, users, force_opt_out=False):
|
||||
"Adds the mandatory and opt_out (only if no services activated for user) services to users"
|
||||
for service in self.services.values():
|
||||
for user in users:
|
||||
if service.optionality == ServiceOptionality.mandatory or (
|
||||
service.optionality == ServiceOptionality.opt_out
|
||||
and ((service.id not in user.services) or force_opt_out)
|
||||
):
|
||||
user.add_service(service.id)
|
||||
|
||||
@classmethod
|
||||
def get_service_x_dirs(cls, x):
|
||||
"""returns a list of package-directories which each represents a specific service.
|
||||
|
|
|
|||
|
|
@ -162,6 +162,13 @@ def init_app(app: SpecterFlask, hwibridge=False, specter=None):
|
|||
specter=specter, devstatus_threshold=app.config["SERVICES_DEVSTATUS_THRESHOLD"]
|
||||
)
|
||||
|
||||
def service_manager_cleanup_on_exit(signum, frame):
|
||||
return specter.service_manager.execute_ext_callbacks(
|
||||
callbacks.cleanup_on_exit, signum, frame
|
||||
)
|
||||
|
||||
specter.call_functions_at_cleanup_on_exit.append(service_manager_cleanup_on_exit)
|
||||
|
||||
specter.initialize()
|
||||
|
||||
# HWI
|
||||
|
|
@ -206,9 +213,17 @@ def init_app(app: SpecterFlask, hwibridge=False, specter=None):
|
|||
from cryptoadvance.specter.server_endpoints import controller
|
||||
from cryptoadvance.specter.services import controller as serviceController
|
||||
|
||||
# this number of view_functions needs to be updated by hand when some are added or removed.
|
||||
number_of_expected_view_functions = 105
|
||||
if app.config.get("TESTING"):
|
||||
logger.info(f"We have {len(app.view_functions)} view Functions")
|
||||
if app.config.get("TESTING") and len(app.view_functions) <= 51:
|
||||
logger.info(
|
||||
f"We have {len(app.view_functions)} view Functions. "
|
||||
f"There should be {number_of_expected_view_functions}."
|
||||
)
|
||||
if (
|
||||
app.config.get("TESTING")
|
||||
and len(app.view_functions) < number_of_expected_view_functions
|
||||
):
|
||||
# Need to force a reload as otherwise the import is skipped
|
||||
# in pytest, the app is created anew for each test
|
||||
# But we shouldn't do that if not necessary as this would result in
|
||||
|
|
@ -277,6 +292,7 @@ def init_app(app: SpecterFlask, hwibridge=False, specter=None):
|
|||
|
||||
scheduler.init_app(app)
|
||||
scheduler.start()
|
||||
specter.service_manager.add_required_services_to_users(specter.user_manager.users)
|
||||
logger.info("----> starting service callback_after_serverpy_init_app ")
|
||||
specter.service_manager.execute_ext_callbacks(
|
||||
after_serverpy_init_app, scheduler=scheduler
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
from flask import flash as flask_flash
|
||||
from flask import current_app as app
|
||||
from ..services import callbacks
|
||||
|
||||
|
||||
def flash(*args, **kwargs):
|
||||
|
|
@ -6,4 +8,11 @@ def flash(*args, **kwargs):
|
|||
This function could be placed in util but as it might
|
||||
use the service_manager, we place it here for now.
|
||||
"""
|
||||
flask_flash(*args, **kwargs)
|
||||
|
||||
return_values = app.specter.service_manager.execute_ext_callbacks(
|
||||
callbacks.flash, *args, **kwargs
|
||||
)
|
||||
|
||||
# if no extension handled the callback
|
||||
if not return_values:
|
||||
flask_flash(*args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ def register():
|
|||
plaintext_password=password,
|
||||
config=config,
|
||||
)
|
||||
app.specter.service_manager.add_required_services_to_users([user])
|
||||
|
||||
flash(
|
||||
_(
|
||||
|
|
|
|||
|
|
@ -57,3 +57,19 @@ flask_before_request = "flask_before_request"
|
|||
Will get called right after having access to app.specter
|
||||
"""
|
||||
specter_added_to_flask_app = "specter_added_to_flask_app"
|
||||
|
||||
"""
|
||||
Will get called when the server_endpoints.flash is called
|
||||
"""
|
||||
flash = "flash"
|
||||
|
||||
"""
|
||||
Callback that is not used yet, but could be implmented in server_endpoints just as flash
|
||||
"""
|
||||
create_and_show_notification = "create_and_show_notification"
|
||||
|
||||
|
||||
"""
|
||||
Callback that is called last in specter.cleanup_on_exit()
|
||||
"""
|
||||
cleanup_on_exit = "cleanup_on_exit"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ devstatus_beta = "beta"
|
|||
devstatus_prod = "prod"
|
||||
|
||||
|
||||
class ServiceOptionality:
|
||||
mandatory = "mandatory" # mandatory is not used yet by any service. Before it can be used it has to be clarified what happens in delete_services_with_encrypted_storage of mandatory services
|
||||
opt_in = "opt_in"
|
||||
opt_out = "opt_out"
|
||||
|
||||
|
||||
class Service:
|
||||
"""A base class for Services"""
|
||||
|
||||
|
|
@ -38,6 +44,8 @@ class Service:
|
|||
# If the blueprint gets a "/ext" prefix (isolated_client = True), the login cookie won't work for all specter core functionality
|
||||
isolated_client = True
|
||||
devstatus = devstatus_alpha
|
||||
optionality = ServiceOptionality.opt_in
|
||||
visible_in_sidebar = True
|
||||
encrypt_data = False
|
||||
|
||||
def __init__(self, active, specter):
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ from .persistence import read_json_file, write_json_file, write_node
|
|||
from .process_controller.bitcoind_controller import BitcoindPlainController
|
||||
from .rpc import BitcoinRPC, RpcError, get_default_datadir
|
||||
from .services.service import devstatus_alpha, devstatus_beta, devstatus_prod
|
||||
from .services import callbacks
|
||||
from .specter_error import ExtProcTimeoutException, SpecterError
|
||||
from .tor_daemon import TorDaemonController
|
||||
from .user import User
|
||||
|
|
@ -79,6 +80,7 @@ class Specter:
|
|||
if not os.path.isdir(data_folder):
|
||||
os.makedirs(data_folder)
|
||||
|
||||
self.call_functions_at_cleanup_on_exit = []
|
||||
self.data_folder = data_folder
|
||||
self._config = config
|
||||
self._internal_bitcoind_version = internal_bitcoind_version
|
||||
|
|
@ -187,6 +189,7 @@ class Specter:
|
|||
signal.signal(signal.SIGINT, self.cleanup_on_exit)
|
||||
# This is for kill $pid --> SIGTERM
|
||||
signal.signal(signal.SIGTERM, self.cleanup_on_exit)
|
||||
# a list of functions that are called at cleanup_on_exit taking in each signum, frame
|
||||
|
||||
def cleanup_on_exit(self, signum=0, frame=0):
|
||||
if self._tor_daemon:
|
||||
|
|
@ -197,6 +200,9 @@ class Specter:
|
|||
if not node.external_node:
|
||||
node.stop()
|
||||
|
||||
for f in self.call_functions_at_cleanup_on_exit:
|
||||
f(signum, frame)
|
||||
|
||||
logger.info("Closing Specter after cleanup")
|
||||
# For some reason we need to explicitely exit here. Otherwise it will hang
|
||||
exit(0)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ body {
|
|||
--cmap-border: #506072;
|
||||
--cmap-border-highlight: #4b8cd8;
|
||||
--cmap-border-darker: #405062;
|
||||
--cmap-bg-address-is-mine: #306d30;
|
||||
--cmap-bg-address-is-mine: #306d30;
|
||||
--cmap-bg-highlight: #4a90e2;
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
|
@ -268,7 +269,7 @@ nav.side > div > .item {
|
|||
|
||||
nav.side > a.item.active,
|
||||
nav.side > div > a.item.active {
|
||||
border-left: 3px solid #4a90e2;
|
||||
border-left: 3px solid var(--cmap-bg-highlight);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
/*color: #fff;*/
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,4 +76,6 @@
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="mx-3 mt-3" id="services_list">
|
||||
{% for _,plugin in specter.service_manager.services.items() %}
|
||||
{% if plugin.id in current_user.services %}
|
||||
{% if plugin.id in current_user.services and plugin.visible_in_sidebar %}
|
||||
<a class="flex items-center space-x-2" href="{{ url_for(plugin.id +'_endpoint.index') }}" class="item service">
|
||||
<img class="w-8 h-8" src="{{ url_for(plugin.id +'_endpoint' + '.static', filename=plugin.icon) }}">
|
||||
<div>{{ plugin.name }}</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
{% extends "base.jinja" %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
<h1>Notification System</h1>
|
||||
<div class="card">
|
||||
<p>
|
||||
The notification System is a flexible way to Notify the user. Other than flask's flash, it's working instantly via WebSockets
|
||||
and takes advantage of <a href="https://developer.mozilla.org/en-US/docs/Web/API/notification">Web Api Notifications</a>.
|
||||
It has been introduced with <a href="https://github.com/cryptoadvance/specter-desktop/pull/1766">#1766</a>
|
||||
</p>
|
||||
<p>
|
||||
It can e.g. be used in javascript like this(see below for the options):
|
||||
</p>
|
||||
<code class="html">
|
||||
<textarea rows="40" cols="90" style="height: 140px">
|
||||
createNotification(title,
|
||||
{
|
||||
target_uis:[targetUis],
|
||||
notification_type: notificationType,
|
||||
body: msgBody,
|
||||
image:'/static/img/ghost_3d.png',
|
||||
timeout:0
|
||||
}
|
||||
)
|
||||
</textarea>
|
||||
</code>
|
||||
<p>
|
||||
In python, you would rather use somehing like:
|
||||
</p>
|
||||
<textarea rows="5" cols="90" style="height: 70px">
|
||||
from cryptoadvance.specter.notifications.current_flask_user import flash
|
||||
|
||||
flash(_("Some Error Message for the user"),"error")
|
||||
</textarea>
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<br><br>
|
||||
|
||||
<div class="card">
|
||||
<p>
|
||||
Let's create a Notification:
|
||||
</p>
|
||||
<form name="aye" onSubmit="return handleIt()">
|
||||
|
||||
<div style="display: flex;">
|
||||
<span>The Title of the message:</span>
|
||||
</div>
|
||||
<p><input type="text" id="title" name="title" value="Some title here!"></p>
|
||||
|
||||
<div style="display: flex;">
|
||||
<span>The Text of the message:</span>
|
||||
</div>
|
||||
<p><input type="text" id="msgBody" name="msgBody" value="Some body text here!"></p>
|
||||
|
||||
<div style="display: flex;">
|
||||
<span>{{ _("The target_uis") }}:</span>
|
||||
<tool-tip width="400px">
|
||||
<span slot="paragraph">
|
||||
<ul>
|
||||
<li><b>js_message_box</b> the default</li>
|
||||
<li><b>webapi</b> uses <a href="https://developer.mozilla.org/en-US/docs/Web/API/notification">Web Api Notifications</a></li>
|
||||
<li><b>js_console</b> same as console.log("bla")</li>
|
||||
<li><b>flash</b> same as flash("some message")</li>
|
||||
<li><b>logging</b> same as logger.info("Some message")</li>
|
||||
</ul>
|
||||
</span>
|
||||
</tool-tip>
|
||||
</div>
|
||||
<select name="target_uis" id="target_uis" tabindex="1">
|
||||
<option value="js_message_box">js_message_box</option>
|
||||
<option value="webapi">webapi</option>
|
||||
<option value="js_console">js_console</option>
|
||||
<option value="flash">flash</option>
|
||||
<option value="logging">logging</option>
|
||||
</select>
|
||||
<br><br>
|
||||
|
||||
<div style="display: flex;">
|
||||
<span>{{ _("The Notification Type") }}:</span>
|
||||
<tool-tip width="400px">
|
||||
<span slot="paragraph">
|
||||
This is called category in flash. Some sort of severity
|
||||
</tool-tip>
|
||||
</div>
|
||||
<select name="notification_type" id="notification_type" tabindex="2">
|
||||
<option value="debug">debug</option>
|
||||
<option value="information" selected>information</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="error">error</option>
|
||||
<option value="exception">exception</option>
|
||||
</select>
|
||||
<br><br>
|
||||
|
||||
<div style="display: flex;">
|
||||
<span>An optional image:</span>
|
||||
</div>
|
||||
<p><input type="text" id="image_url" name="image_url" value="/static/img/ghost_3d.png"></p>
|
||||
<div style="display: flex;">
|
||||
<span>Timeout:</span>
|
||||
</div>
|
||||
<p><input type="timeout" id="timeout" value=3000></p>
|
||||
|
||||
<br><br>
|
||||
<input name="Submit" class="btn" type="submit" value="Submit" />
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="text/javascript">
|
||||
|
||||
function handleIt() {
|
||||
var title = document.getElementById("title").value;
|
||||
var msgBody = document.getElementById("msgBody").value;
|
||||
var targetUis = document.getElementById("target_uis").value;
|
||||
var notificationType = document.getElementById("notification_type").value;
|
||||
var image_url = document.getElementById("image_url").value;
|
||||
var timeout = document.getElementById("timeout").value;
|
||||
console.log(notificationType)
|
||||
createNotification(title,
|
||||
{
|
||||
target_uis:[targetUis],
|
||||
notification_type: notificationType,
|
||||
body: msgBody,
|
||||
image:image_url,
|
||||
timeout:parseInt(timeout)
|
||||
}
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -19,7 +19,8 @@
|
|||
</ul>
|
||||
|
||||
<p>
|
||||
Same for some macros.
|
||||
Exploring/testing the <a href="html/notification-system.jinja">Notification System</a>
|
||||
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
|
|
|
|||
0
src/cryptoadvance/specterext/notifications/__init__.py
Normal file
0
src/cryptoadvance/specterext/notifications/__init__.py
Normal file
25
src/cryptoadvance/specterext/notifications/config.py
Normal file
25
src/cryptoadvance/specterext/notifications/config.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""
|
||||
Here Configuration of your Extension takes place
|
||||
"""
|
||||
|
||||
|
||||
class BaseConfig:
|
||||
"""This is a extension-based Config which is used as Base"""
|
||||
|
||||
SPECTER_NOTIFICATIONS_WEBSOCKETS_ENABLED = True
|
||||
|
||||
|
||||
class ProductionConfig(BaseConfig):
|
||||
"""This is a extension-based Config for Production"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestConfig(BaseConfig):
|
||||
"""This is a extension-based Config for Production"""
|
||||
|
||||
SPECTER_NOTIFICATIONS_WEBSOCKETS_ENABLED = False
|
||||
|
||||
|
||||
class CypressTestConfig(TestConfig):
|
||||
SPECTER_NOTIFICATIONS_WEBSOCKETS_ENABLED = True
|
||||
90
src/cryptoadvance/specterext/notifications/controller.py
Normal file
90
src/cryptoadvance/specterext/notifications/controller.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import logging
|
||||
import json
|
||||
from flask import redirect, render_template, request, url_for, Response
|
||||
from flask import current_app as app
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from cryptoadvance.specter.specter import Specter
|
||||
from cryptoadvance.specter.services.controller import user_secret_decrypted_required
|
||||
from cryptoadvance.specter.user import User
|
||||
from cryptoadvance.specter.wallet import Wallet
|
||||
from .service import NotificationsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
notifications_endpoint = NotificationsService.blueprint
|
||||
|
||||
|
||||
def ext() -> NotificationsService:
|
||||
"""convenience for getting the extension-object"""
|
||||
return app.specter.ext["notifications"]
|
||||
|
||||
|
||||
def specter() -> Specter:
|
||||
"""convenience for getting the specter-object"""
|
||||
return app.specter
|
||||
|
||||
|
||||
@notifications_endpoint.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
return render_template(
|
||||
"notifications/index.jinja",
|
||||
)
|
||||
|
||||
|
||||
@notifications_endpoint.route("/settings", methods=["GET"])
|
||||
@login_required
|
||||
def settings_get():
|
||||
user = app.specter.user_manager.get_user()
|
||||
return render_template(
|
||||
"notifications/settings.jinja",
|
||||
cookies=request.cookies,
|
||||
show_menu=NotificationsService.id in user.services,
|
||||
)
|
||||
|
||||
|
||||
@notifications_endpoint.route("/settings", methods=["POST"])
|
||||
@login_required
|
||||
def settings_post():
|
||||
show_menu = request.form["show_menu"]
|
||||
user = app.specter.user_manager.get_user()
|
||||
if show_menu == "yes":
|
||||
user.add_service(NotificationsService.id)
|
||||
else:
|
||||
user.remove_service(NotificationsService.id)
|
||||
return redirect(
|
||||
url_for(f"{ NotificationsService.get_blueprint_name()}.settings_get")
|
||||
)
|
||||
|
||||
|
||||
@notifications_endpoint.route("/websocket", websocket=True)
|
||||
def websocket():
|
||||
logger.debug("websocket route called. This will start a new websocket connection.")
|
||||
# this function will run forever. That is ok, because a stream is expected, similar to https://maxhalford.github.io/blog/flask-sse-no-deps/
|
||||
# flask.Response(stream(), mimetype='text/event-stream')
|
||||
if app.specter.ext["notifications"].notification_manager.websockets_server:
|
||||
app.specter.ext["notifications"].notification_manager.websockets_server.serve(
|
||||
request.environ
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"/websocket route accessed, but no websockets_server is initialized."
|
||||
)
|
||||
# returning something solved some error message when the function ends: https://stackoverflow.com/questions/25034123/flask-value-error-view-function-did-not-return-a-response
|
||||
# and this mimetype does not trigger slow_request_detection_stop
|
||||
# however when connected via ssl the following error still occurs
|
||||
# ssl.SSLEOFError: EOF occurred in violation of protocol (_ssl.c:2384)
|
||||
return Response(json.dumps({}), mimetype="application/json")
|
||||
|
||||
|
||||
@notifications_endpoint.route("/get_websockets_info/", methods=["GET"])
|
||||
@login_required
|
||||
def get_websockets_info():
|
||||
return json.dumps(
|
||||
{
|
||||
"user_token": app.specter.ext[
|
||||
"notifications"
|
||||
].notification_manager.get_websocket_token(current_user.username),
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,485 @@
|
|||
import logging
|
||||
import secrets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .notifications import Notification
|
||||
from ..notifications import ui_notifications
|
||||
from ..notifications import websockets_server_client
|
||||
|
||||
|
||||
INTERNAL_NOTIFICATION_TITLES = {"set_target_ui_availability", "on_show", "on_close"}
|
||||
|
||||
|
||||
class NotificationManager:
|
||||
"""
|
||||
This class allows to register ui_notifications (like JSNotifications).
|
||||
|
||||
1. Notifications can be created and broadcasted with self.create_and_show
|
||||
This will forward the notification to all appropriate ui_notifications
|
||||
The notifications are also stored in self.notifications.
|
||||
|
||||
Notifications has optimistic notification delivery, meaning:
|
||||
- the ui_notifications (like JSNotifications) immediately returns True, meaning the notification
|
||||
was delivered, if the ui_notification is not deactivated or has other reasons to deny delivery immediately.
|
||||
- If the ui_notifications (like JSNotifications) then later detects it failed to deliver
|
||||
after all, the ui_notification has to send a set_target_ui_availability (data.is_available=False, data.notification_id),
|
||||
which deactivates the ui_notification and rebroadcasts the notification on other available ui_notifications
|
||||
- This optimistic notification delivery was chosen, mainly because the ui_notifications vary greatly
|
||||
preventing a common timeout. Only the ui_notification knows how long and best it should retry displaying the notification.
|
||||
The best example is Notification_API, which may not have the permission to display yet, but can ask for permission
|
||||
repeatedly. The user has the choice to grant it or to deny it, or to wait.
|
||||
The print notification on the other hand will always work.
|
||||
- final confirmation that the message was seen by the user in the on_show notification sent by the ui_notification
|
||||
|
||||
|
||||
2. Internal notifications will not be shown, but
|
||||
a) deletes the message again from self.notifications
|
||||
b) trigger functions like ui_notifications.on_close
|
||||
|
||||
If the ui_notification noticed later (async), that the notification was not delivered, then the ui_notification call
|
||||
create_notification('set_target_ui_availability', user_id, target_uis=["internal_notification"], is_available=False)
|
||||
and the ui_notification will deactivate and the notification will be rebroadcasted
|
||||
|
||||
|
||||
|
||||
1. Notifications to UINotifications
|
||||
┌───────────────────────┐
|
||||
┌─────────────────────► │ FlashNotifications │
|
||||
│ └───────────────────────┘
|
||||
┌───────────────────────┐ │ ┌───────────────────────┐
|
||||
│ NotificationManager │ ├─────────────────────► │ JSConsoleNotifications│
|
||||
│ .create_and_show │ │ │ │
|
||||
└───────────────────────┘ │ └───────────────────────┘
|
||||
│ ┌───────────────────────┐
|
||||
└──────────────────────►│ JSNotifications │
|
||||
└───────────────────────┘
|
||||
|
||||
2. internal_notification at the example of on_close
|
||||
|
||||
┌───────────────────────┐
|
||||
│ │ if "internal_notification" in notification.target_uis:
|
||||
│ NotificationManager │ ──────────────────────────────────────►┐
|
||||
│ .create_and_show │ │
|
||||
└───────────────────────┘ ▼
|
||||
┌────────────────────────────┐
|
||||
│ NotificationManager │
|
||||
│ ._treat_internal_message │
|
||||
│ e.g. on_close │
|
||||
└─────────────────┬──────────┘
|
||||
│ on_close
|
||||
▼
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ JSNotifications │ ◄─────────────────────┐ │ NotificationManager │
|
||||
│ .on_close │ │ │ ._internal_on_close │
|
||||
└───────────────────────┘ │ └───────────────────────┘
|
||||
┌───────────────────────┐ │
|
||||
│ NotificationManager │ │
|
||||
│ ._delete_notification│ ◄─────────────────────┘
|
||||
└───────────────────────┘
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host,
|
||||
port,
|
||||
ssl_cert=None,
|
||||
ssl_key=None,
|
||||
ui_notifications=None,
|
||||
enable_websockets=True,
|
||||
notifications_endpoint_url="svc/notifications",
|
||||
verbose_debug=False,
|
||||
):
|
||||
"""
|
||||
Arguments:
|
||||
- ui_notifications: {user_id: [list of ui_notifications]}
|
||||
The "default" ui_notifications is at position 0
|
||||
"""
|
||||
self.ui_notifications = ui_notifications if ui_notifications else []
|
||||
self.notifications = []
|
||||
self._websocket_tokens = {}
|
||||
self.ssl_cert, self.ssl_key = ssl_cert, ssl_key
|
||||
self._register_default_ui_notifications()
|
||||
|
||||
self.verbose_debug = verbose_debug
|
||||
self.websockets_server = None
|
||||
self.websockets_client = None
|
||||
if enable_websockets:
|
||||
self.websockets_server = websockets_server_client.WebsocketServer(self)
|
||||
|
||||
# '/'.join([notifications_endpoint_url, "websocket"]) should be replaced by url_for('notifications_endpoint.websocket')
|
||||
# but this leads to an error
|
||||
self.websockets_client = websockets_server_client.WebsocketClient(
|
||||
host,
|
||||
port,
|
||||
"/".join([notifications_endpoint_url, "websocket"]),
|
||||
self.ssl_cert,
|
||||
self.ssl_key,
|
||||
)
|
||||
|
||||
# setting this client as broadcaster, meaning it is allowed to send to all
|
||||
# connected websocket connections without restrictions
|
||||
self.websockets_server.set_as_broadcaster(self.websockets_client.user_token)
|
||||
self.websockets_client.start()
|
||||
|
||||
def get_websocket_token(self, user_id):
|
||||
if user_id not in self._websocket_tokens:
|
||||
self._websocket_tokens[user_id] = secrets.token_urlsafe(128)
|
||||
|
||||
return self._websocket_tokens[user_id]
|
||||
|
||||
@property
|
||||
def websocket_tokens(self):
|
||||
return self._websocket_tokens
|
||||
|
||||
def quit(self):
|
||||
if self.websockets_client and self.websockets_client.is_connected():
|
||||
self.websockets_client.quit_server()
|
||||
logger.debug(f"{self.__class__.__name__} quit was called.")
|
||||
|
||||
def _register_default_ui_notifications(self):
|
||||
"Registers up the logging and print UINotifications, that can be used by alll users (user_id=None)"
|
||||
self.register_ui_notification(ui_notifications.LoggingNotifications())
|
||||
self.register_ui_notification(ui_notifications.PrintNotifications())
|
||||
# the flask context handles here the user assignment, such that no user_id should be given here
|
||||
self.register_ui_notification(ui_notifications.FlashNotifications())
|
||||
|
||||
def register_user_ui_notifications(self, user_id):
|
||||
"Registers up the (default) UINotifications for this user"
|
||||
if self.websockets_server:
|
||||
self.register_ui_notification(
|
||||
ui_notifications.WebAPINotifications(user_id, self.websockets_client)
|
||||
)
|
||||
|
||||
if self.websockets_server:
|
||||
self.register_ui_notification(
|
||||
ui_notifications.JSNotifications(user_id, self.websockets_client)
|
||||
)
|
||||
|
||||
if self.websockets_server:
|
||||
self.register_ui_notification(
|
||||
ui_notifications.JSConsoleNotifications(user_id, self.websockets_client)
|
||||
)
|
||||
|
||||
def register_ui_notification(self, ui_notification):
|
||||
"""
|
||||
Appends the ui_notification at the end of the self.ui_notifications.
|
||||
|
||||
It can then be explicitly used via notification.target_uis = {ui_notification.name}
|
||||
or is used automatically as a fallback if all previous ui_notifications are not available
|
||||
"""
|
||||
logger.debug(
|
||||
f'Registering "{ui_notification.name}" for user "{ui_notification.user_id}" in {self.__class__.__name__}'
|
||||
)
|
||||
self.ui_notifications.append(ui_notification)
|
||||
|
||||
def _find_target_ui(self, target_ui, user_id):
|
||||
"Returns the ui_notification matching (target_ui, user_id)"
|
||||
for ui_notification in self.ui_notifications:
|
||||
if (ui_notification.name == target_ui) and (
|
||||
(ui_notification.user_id == user_id or ui_notification.user_id is None)
|
||||
):
|
||||
return ui_notification
|
||||
|
||||
def get_default_target_ui_name(self):
|
||||
"Returns the first ui_notifications"
|
||||
return self.ui_notifications[0].name if self.ui_notifications else None
|
||||
|
||||
def get_all_target_ui_names(self):
|
||||
"Returns the names of all ui_notifications"
|
||||
return {ui_notification.name for ui_notification in self.ui_notifications}
|
||||
|
||||
def get_notification_by_id(self, notification_id):
|
||||
"Finds and returns the notification with notification_id"
|
||||
for notification in self.notifications:
|
||||
if notification.id == notification_id:
|
||||
return notification
|
||||
|
||||
def _get_ui_notifications_of_user(
|
||||
self, user_id, callable_from_any_session_required=False
|
||||
):
|
||||
"Gives a back a [ui_notifications that belong to the user_id] + [ui_notifications that belong to user_id == None]"
|
||||
user_targeted_ui_notification = (
|
||||
[
|
||||
ui_notification
|
||||
for ui_notification in self.ui_notifications
|
||||
if ui_notification.user_id == user_id
|
||||
and (
|
||||
ui_notification.callable_from_any_session
|
||||
or not callable_from_any_session_required
|
||||
)
|
||||
]
|
||||
if user_id
|
||||
else []
|
||||
)
|
||||
|
||||
public_ui_notification = [
|
||||
ui_notification
|
||||
for ui_notification in self.ui_notifications
|
||||
if ui_notification.user_id is None
|
||||
and (
|
||||
ui_notification.callable_from_any_session
|
||||
or not callable_from_any_session_required
|
||||
)
|
||||
]
|
||||
|
||||
return user_targeted_ui_notification + public_ui_notification
|
||||
|
||||
def set_notification_shown(self, notification_id, target_ui):
|
||||
"Calls notification.set_shown"
|
||||
notification = self.get_notification_by_id(notification_id)
|
||||
if not notification:
|
||||
logging.warning(
|
||||
f"set_notification_shown: Notification with id {notification_id} not found"
|
||||
)
|
||||
return
|
||||
notification.set_shown(target_ui)
|
||||
|
||||
def _delete_notification(self, notification):
|
||||
"Deletes the notification from self.notifications"
|
||||
if notification not in self.notifications:
|
||||
logging.warning(
|
||||
f"_delete_notification: notification {notification} was not found in self.notifications"
|
||||
)
|
||||
return
|
||||
|
||||
del self.notifications[self.notifications.index(notification)]
|
||||
if self.verbose_debug:
|
||||
logger.debug(f"Deleted {notification}")
|
||||
|
||||
def set_target_ui_availability(self, target_ui, user_id, is_available):
|
||||
"Sets ui_notification.is_available"
|
||||
ui_notification = self._find_target_ui(target_ui, user_id)
|
||||
if not ui_notification:
|
||||
logger.warning(
|
||||
f"set_target_ui_availability: target_ui {target_ui} could not be found"
|
||||
)
|
||||
return
|
||||
ui_notification.is_available = is_available
|
||||
logger.debug(
|
||||
f"Setting {ui_notification.name} of user {ui_notification.user_id} available = {is_available}"
|
||||
)
|
||||
|
||||
def _internal_set_target_ui_availability(
|
||||
self, internal_notification, referenced_notification
|
||||
):
|
||||
"Calls self.set_target_ui_availability and rebroadcasts the referenced_notification if necessary"
|
||||
logger.debug(
|
||||
f'{internal_notification.data["target_ui"]} is unavailable, now deactivating this target_ui and rebroadcasting'
|
||||
)
|
||||
self.set_target_ui_availability(
|
||||
internal_notification.data["target_ui"],
|
||||
internal_notification.user_id,
|
||||
internal_notification.data["is_available"],
|
||||
)
|
||||
if not internal_notification.data["is_available"]:
|
||||
# if it is not available rebroadcast
|
||||
if not referenced_notification and internal_notification.data.get("id"):
|
||||
logger.warning(
|
||||
f'Rebrodcasting of {internal_notification.data.get("id")} not possible because referenced_notification={referenced_notification}'
|
||||
)
|
||||
return
|
||||
logger.debug(f"Rebrodcasting {referenced_notification.id}")
|
||||
self.show(referenced_notification)
|
||||
|
||||
def _internal_on_show(self, internal_notification, referenced_notification):
|
||||
"Calls self.set_notification_shown and ui_notification.on_show"
|
||||
if not referenced_notification:
|
||||
return
|
||||
|
||||
self.set_notification_shown(
|
||||
referenced_notification.id,
|
||||
internal_notification.data["target_ui"],
|
||||
)
|
||||
ui_notification = self._find_target_ui(
|
||||
internal_notification.data["target_ui"], referenced_notification.user_id
|
||||
)
|
||||
# perhaps the target_ui was not available and it was displayed in another ui_notification.
|
||||
# However still call on_show of the original target_ui
|
||||
if ui_notification.on_show:
|
||||
ui_notification.on_show(
|
||||
referenced_notification.id,
|
||||
internal_notification.data["target_ui"],
|
||||
)
|
||||
|
||||
def _notification_can_be_deleted(self, notification, on_close_was_called=False):
|
||||
"Checks if the target_ui was set in was_closed_in_target_uis, if the target_ui is available"
|
||||
available_target_uis = {
|
||||
target_ui
|
||||
for target_ui in notification.target_uis
|
||||
if self._find_target_ui(target_ui, notification.user_id)
|
||||
and self._find_target_ui(target_ui, notification.user_id).is_available
|
||||
}
|
||||
|
||||
if not on_close_was_called and not available_target_uis:
|
||||
logger.debug(
|
||||
f"None of the notification.target_uis {notification.target_uis} are available and on_close was not called yet."
|
||||
" Keeping this transaction, such that it can be rebroadcastes later."
|
||||
)
|
||||
return False
|
||||
|
||||
if self.verbose_debug:
|
||||
logger.debug(
|
||||
f"Notification {notification.id} of user {notification.user_id} was_closed_in_target_uis = {notification.was_closed_in_target_uis}"
|
||||
)
|
||||
# it it was closed in all available_target_uis, then go ahead and delete the notification
|
||||
return not (available_target_uis - notification.was_closed_in_target_uis)
|
||||
|
||||
def _internal_on_close(self, internal_notification, referenced_notification):
|
||||
"calls on_close and deletes the notification afterwards"
|
||||
if not referenced_notification:
|
||||
return
|
||||
|
||||
ui_notification = self._find_target_ui(
|
||||
internal_notification.data["target_ui"], referenced_notification.user_id
|
||||
)
|
||||
# perhaps the target_ui was not available and it was displayed in another ui_notification.
|
||||
# However still call on_close of the original target_ui
|
||||
if ui_notification.on_close:
|
||||
ui_notification.on_close(
|
||||
referenced_notification.id,
|
||||
internal_notification.data["target_ui"],
|
||||
)
|
||||
|
||||
referenced_notification.set_closed(internal_notification.data["target_ui"])
|
||||
if self._notification_can_be_deleted(
|
||||
referenced_notification, on_close_was_called=True
|
||||
):
|
||||
self._delete_notification(referenced_notification)
|
||||
|
||||
def _treat_internal_message(self, internal_notification):
|
||||
"""
|
||||
Notifications with the title='internal_notification' are not displayed to the user.
|
||||
It allows calling the internal functions for:
|
||||
- "on_close"
|
||||
- "on_show"
|
||||
- "set_target_ui_availability" (if not available, the notification is rebroadcasted)
|
||||
"""
|
||||
if "internal_notification" not in internal_notification.target_uis:
|
||||
return internal_notification
|
||||
if self.verbose_debug:
|
||||
logger.debug(f"_treat_internal_message {internal_notification}")
|
||||
|
||||
referenced_notification = self.get_notification_by_id(
|
||||
internal_notification.data.get("id")
|
||||
)
|
||||
|
||||
if internal_notification.title in INTERNAL_NOTIFICATION_TITLES:
|
||||
method = getattr(self, f"_internal_{internal_notification.title}", None)
|
||||
if method:
|
||||
method(internal_notification, referenced_notification)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Could not call the method _internal_{internal_notification.title}"
|
||||
)
|
||||
|
||||
def create_notification(self, title, user_id, **kwargs):
|
||||
"""
|
||||
Creates a notification (which adds it to self.notifications) and also broadcasts it to ui_notifications.
|
||||
kwargs are the optional arguments of Notification
|
||||
"""
|
||||
if self.verbose_debug:
|
||||
logger.debug(
|
||||
f"Starting to create notification with title, **kwargs {title, kwargs}"
|
||||
)
|
||||
|
||||
notification = Notification(
|
||||
title,
|
||||
self.get_default_target_ui_name(),
|
||||
self.get_all_target_ui_names(),
|
||||
user_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# treat an internal (notification) message
|
||||
if "internal_notification" in notification.target_uis:
|
||||
# in case _treat_internal_message returns a notification, then proceed with that
|
||||
return self._treat_internal_message(notification)
|
||||
|
||||
self.notifications.append(notification)
|
||||
if self.verbose_debug:
|
||||
logger.debug(f"Created notification {notification}")
|
||||
return notification
|
||||
|
||||
def show(self, notification):
|
||||
"""
|
||||
forwards the notification to ui_notifications, that are in notification['target_uis']
|
||||
|
||||
If a target_ui of notification['target_uis'] is not is_available, then try with the next target_ui
|
||||
"""
|
||||
if notification.target_uis == ["internal_notification"]:
|
||||
return
|
||||
|
||||
ui_notifications_of_user = self._get_ui_notifications_of_user(
|
||||
notification.user_id
|
||||
)
|
||||
if self.verbose_debug:
|
||||
logger.debug(f"ui_notifications_of_user {ui_notifications_of_user}")
|
||||
broadcast_on_ui_notifications = [
|
||||
ui_notification
|
||||
for ui_notification in ui_notifications_of_user
|
||||
if (ui_notification.name in notification.target_uis)
|
||||
]
|
||||
|
||||
for ui_notification in broadcast_on_ui_notifications:
|
||||
logger.debug(
|
||||
f"Showing notification {notification.id} on {ui_notification.name}"
|
||||
)
|
||||
|
||||
notification_broadcasted = ui_notification.show(notification)
|
||||
|
||||
# if not possible, then try show it with a ui_notifications that is NOT already in notification['target_uis']
|
||||
if not notification_broadcasted:
|
||||
if self.verbose_debug:
|
||||
logger.debug(
|
||||
f"Trying with other ui_notifications to broadcast {notification}"
|
||||
)
|
||||
# I have to restrict the ui_notifications that are used as a backup to callable_from_any_session_required
|
||||
# because it it not possible to call a flash notification from another thread (that failed doing a webapi notification)
|
||||
for backup_ui_notification in self._get_ui_notifications_of_user(
|
||||
notification.user_id, callable_from_any_session_required=True
|
||||
):
|
||||
# if it is already broadcasted on this backup_ui_notification by default anyway, no need to do it twice
|
||||
if backup_ui_notification in broadcast_on_ui_notifications:
|
||||
continue
|
||||
notification_broadcasted = backup_ui_notification.show(notification)
|
||||
if self.verbose_debug:
|
||||
logger.debug(
|
||||
f"Rebroadcasted on {backup_ui_notification.name} of {notification} "
|
||||
)
|
||||
if notification_broadcasted:
|
||||
break
|
||||
# for some ui_notifications, the last_shown_date and was_closed_in_target_uis is set immediately.
|
||||
# then the message can also be delted immediately
|
||||
if self._notification_can_be_deleted(notification):
|
||||
self._delete_notification(notification)
|
||||
|
||||
def flash(self, message: str, user_id, category: str = "message"):
|
||||
"Creates and shows a flask flash message"
|
||||
|
||||
# translate categories
|
||||
if category == "info":
|
||||
category = "information"
|
||||
if category == "warn":
|
||||
category = "warning"
|
||||
|
||||
return self.create_and_show(
|
||||
message, user_id, notification_type=category, target_uis={"flash"}
|
||||
)
|
||||
|
||||
def create_and_show(self, title, user_id, **kwargs):
|
||||
"""Creates a notification (which adds it to self.notifications) and also broadcasts it to ui_notifications.
|
||||
|
||||
Args:
|
||||
title (str): _description_
|
||||
user_id (str): The user.username (or flask.current_user), to whom the notification should be sent
|
||||
kwargs: are the optional arguments and will be passed to Notification()
|
||||
|
||||
Returns:
|
||||
Notification(): _description_
|
||||
"""
|
||||
notification = self.create_notification(title, user_id, **kwargs)
|
||||
if notification:
|
||||
self.show(notification)
|
||||
return notification
|
||||
166
src/cryptoadvance/specterext/notifications/notifications.py
Normal file
166
src/cryptoadvance/specterext/notifications/notifications.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
import datetime
|
||||
import hashlib
|
||||
|
||||
|
||||
class NotificationTypes:
|
||||
debug = "debug"
|
||||
information = "information"
|
||||
warning = "warning"
|
||||
error = "error"
|
||||
exception = "exception"
|
||||
|
||||
|
||||
class Notification:
|
||||
"""
|
||||
A Notification is a datastructure to store title, body, ...
|
||||
The field names should be ideally identical to https://notifications.spec.whatwg.org/#api
|
||||
Additional fields, like id, can be added
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title,
|
||||
default_target_ui,
|
||||
all_target_uis,
|
||||
user_id,
|
||||
target_uis="default", # "default" will be replaced by default_target_ui
|
||||
notification_type=None,
|
||||
body=None,
|
||||
data=None,
|
||||
image=None,
|
||||
icon=None,
|
||||
timeout=None,
|
||||
date=None,
|
||||
verbose_debug=False,
|
||||
):
|
||||
self.title = str(title)
|
||||
self.user_id = user_id
|
||||
self.date = date if date else datetime.datetime.now()
|
||||
self.last_shown_date = dict() # structure {'target_ui' : date}
|
||||
self.was_closed_in_target_uis = set() # example: {'webapi', 'logging'}
|
||||
|
||||
if not target_uis:
|
||||
target_uis = "default"
|
||||
self.target_uis = (
|
||||
{target_uis} if isinstance(target_uis, str) else set(target_uis)
|
||||
)
|
||||
self.substitute_special_target_uis(default_target_ui, all_target_uis)
|
||||
|
||||
# clean up invalid NotificationTypes
|
||||
self.notification_type = (
|
||||
notification_type if notification_type else NotificationTypes.information
|
||||
)
|
||||
if self.notification_type not in {
|
||||
NotificationTypes.debug,
|
||||
NotificationTypes.information,
|
||||
NotificationTypes.warning,
|
||||
NotificationTypes.error,
|
||||
NotificationTypes.exception,
|
||||
}:
|
||||
self.notification_type = NotificationTypes.information
|
||||
|
||||
self.body = body
|
||||
self.data = data
|
||||
self.image = image
|
||||
self.icon = icon
|
||||
self.timeout = timeout # [ms]
|
||||
|
||||
self.verbose_debug = verbose_debug
|
||||
# set id (dependent on (almost) all other properties, so must be set last)
|
||||
self.id = None
|
||||
self._set_id()
|
||||
|
||||
def __str__(self):
|
||||
# .copy() is essential here, otherwise one actually deletes verbose_debug from the object
|
||||
reduced_dict = self.__dict__.copy()
|
||||
del reduced_dict["verbose_debug"]
|
||||
return str(reduced_dict)
|
||||
|
||||
def _set_id(self):
|
||||
# .copy() is essential here, otherwise one actually deletes id, verbose_debug from the object
|
||||
reduced_dict = self.__dict__.copy()
|
||||
del reduced_dict["id"]
|
||||
del reduced_dict["verbose_debug"]
|
||||
self.id = hashlib.sha256(str(reduced_dict).encode()).hexdigest()
|
||||
|
||||
def set_shown(self, target_ui, date=None):
|
||||
self.last_shown_date[target_ui] = date if date else datetime.datetime.now()
|
||||
if self.verbose_debug:
|
||||
logger.debug(f"set_notification_shown {self}")
|
||||
|
||||
def set_closed(self, target_ui):
|
||||
self.was_closed_in_target_uis.add(target_ui)
|
||||
if self.verbose_debug:
|
||||
logger.debug(f"set_closed {self}")
|
||||
|
||||
def substitute_special_target_uis(self, default_target_ui, all_target_uis):
|
||||
"""
|
||||
It replaces the valid target_uis:
|
||||
- "default" by the default_target_ui
|
||||
- "all" by all_target_uis
|
||||
|
||||
Args:
|
||||
default_target_ui (_type_): _description_
|
||||
all_target_uis (_type_): _description_
|
||||
"""
|
||||
# clean up the notification['target_uis']
|
||||
if "internal_notification" in self.target_uis:
|
||||
# no cleanup for internal_notification
|
||||
return
|
||||
|
||||
if "all" in self.target_uis:
|
||||
target_uis = all_target_uis
|
||||
|
||||
# replace the "default" target_ui with the 0.th ui_notifications
|
||||
if "default" in self.target_uis:
|
||||
self.target_uis.remove("default")
|
||||
if default_target_ui:
|
||||
self.target_uis.add(default_target_ui)
|
||||
|
||||
def to_js_notification(self):
|
||||
"""
|
||||
Returns the following data structure:
|
||||
{
|
||||
"title": title,
|
||||
"id": id,
|
||||
"notification_type": notification_type,
|
||||
"timeout": timeout,
|
||||
"options": {
|
||||
....
|
||||
},
|
||||
}
|
||||
|
||||
The "options" dict is contains self.__dict__.items(), unless they are included in the main dict.
|
||||
The "options" dict could contain all fields as https://notifications.spec.whatwg.org/#api ,
|
||||
however not even for target_uis = 'web_api' are all fields supported
|
||||
|
||||
The following circular call , will preserve only the (optional) arguments supported by Notification.__init__()
|
||||
(python) js_notification = notification.to_js_notification()
|
||||
--> (javascript) createNotification(js_notification['title'], js_notification['options']) --> title, options
|
||||
--> (python) notification = Notification(title, ..., **options)
|
||||
"""
|
||||
js_notification = {
|
||||
"title": self.title,
|
||||
"id": self.id,
|
||||
"notification_type": self.notification_type,
|
||||
"timeout": self.timeout,
|
||||
"options": {},
|
||||
}
|
||||
|
||||
for key, value in self.__dict__.items():
|
||||
if key in js_notification or value is None:
|
||||
continue
|
||||
js_notification["options"][key] = value
|
||||
|
||||
return js_notification
|
||||
|
||||
def to_text(self):
|
||||
s = self.title
|
||||
if self.body:
|
||||
s += f"\n{self.body}"
|
||||
if self.data:
|
||||
s += f"\nData: {self.data}"
|
||||
return s
|
||||
88
src/cryptoadvance/specterext/notifications/service.py
Normal file
88
src/cryptoadvance/specterext/notifications/service.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import logging
|
||||
|
||||
from cryptoadvance.specter.services.service import (
|
||||
Service,
|
||||
devstatus_alpha,
|
||||
devstatus_prod,
|
||||
devstatus_beta,
|
||||
ServiceOptionality,
|
||||
)
|
||||
|
||||
# A SpecterError can be raised and will be shown to the user as a red banner
|
||||
from cryptoadvance.specter.specter_error import SpecterError
|
||||
from flask import render_template
|
||||
from cryptoadvance.specter.wallet import Wallet
|
||||
from flask_apscheduler import APScheduler
|
||||
from .notification_manager import NotificationManager
|
||||
from flask_login import current_user, AnonymousUserMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationsService(Service):
|
||||
id = "notifications"
|
||||
name = "Notifications Service"
|
||||
icon = "notifications/img/notification.png"
|
||||
logo = "notifications/img/notification.png"
|
||||
desc = "Where a notifications grows bigger."
|
||||
has_blueprint = True
|
||||
blueprint_module = "cryptoadvance.specterext.notifications.controller"
|
||||
devstatus = devstatus_alpha
|
||||
isolated_client = False
|
||||
|
||||
# TODO: As more Services are integrated, we'll want more robust categorization and sorting logic
|
||||
sort_priority = 2
|
||||
optionality = ServiceOptionality.opt_in
|
||||
visible_in_sidebar = False
|
||||
|
||||
def callback_after_serverpy_init_app(self, scheduler: APScheduler):
|
||||
"""
|
||||
Args:
|
||||
scheduler (APScheduler): _description_
|
||||
app (_type_): While in other services app is optional, it is required here. python will automatically map the kwargs['app'] to this app
|
||||
"""
|
||||
self.scheduler = scheduler
|
||||
|
||||
notifications_endpoint_url = "/".join(
|
||||
[scheduler.app.config["EXT_URL_PREFIX"], self.id]
|
||||
)
|
||||
# remove leading /
|
||||
if notifications_endpoint_url.startswith("/"):
|
||||
notifications_endpoint_url = notifications_endpoint_url[1:]
|
||||
|
||||
self.notification_manager = NotificationManager(
|
||||
scheduler.app.config.get("HOST", "127.0.0.1"),
|
||||
scheduler.app.config["PORT"],
|
||||
scheduler.app.config["CERT"],
|
||||
scheduler.app.config["KEY"],
|
||||
enable_websockets=scheduler.app.config[
|
||||
"SPECTER_NOTIFICATIONS_WEBSOCKETS_ENABLED"
|
||||
],
|
||||
notifications_endpoint_url=notifications_endpoint_url,
|
||||
)
|
||||
for user in scheduler.app.specter.user_manager.users:
|
||||
self.notification_manager.register_user_ui_notifications(user.id)
|
||||
|
||||
# There might be other callbacks you're interested in. Check the callbacks.py in the specter-desktop source.
|
||||
# if you are, create a method here which is "callback_" + callback_id
|
||||
|
||||
def callback_flash(self, message: str, category: str = "message"):
|
||||
username = (
|
||||
current_user if not isinstance(current_user, AnonymousUserMixin) else None
|
||||
)
|
||||
return self.notification_manager.flash(message, username, category)
|
||||
|
||||
def callback_create_and_show_notification(self, title, **kwargs):
|
||||
username = (
|
||||
current_user if not isinstance(current_user, AnonymousUserMixin) else None
|
||||
)
|
||||
|
||||
return self.notification_manager.create_and_show(title, username, **kwargs)
|
||||
|
||||
def callback_cleanup_on_exit(self, signum=0, frame=0):
|
||||
logger.debug(f"callback_cleanup_on_exit called of {self.__class__.__name__}")
|
||||
self.notification_manager.quit()
|
||||
|
||||
@classmethod
|
||||
def inject_in_basejinja_body_top(cls):
|
||||
return render_template("notifications/html_inject_in_basejinja.jinja")
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/* This is the place to put all your styles */
|
||||
|
||||
|
||||
html, body{
|
||||
/* msgbox colors*/
|
||||
--cmap-msgbox-debug: #000000;
|
||||
--cmap-msgbox-information: #4a90e2;
|
||||
--cmap-msgbox-warning: #FF9A00;
|
||||
--cmap-msgbox-error: #951E2D;
|
||||
--cmap-msgbox-exception: #951E2D;
|
||||
}
|
||||
|
||||
|
||||
/*****************************/
|
||||
/* MESSAGE BOX RELATED CLASS */
|
||||
/* (START) */
|
||||
/*****************************/
|
||||
.msgbox-area {
|
||||
font-size: inherit;
|
||||
max-height: 100%;
|
||||
position: fixed;
|
||||
bottom: 15px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
z-index: 10;
|
||||
}
|
||||
.msgbox-box {
|
||||
font-size: inherit;
|
||||
color: #fff;
|
||||
background-color: var(--cmap-msgbox-information);
|
||||
border: 2px var(--cmap-border) solid;
|
||||
margin: 0 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 15px rgba(0, 0, 0, 1);
|
||||
backdrop-filter: blur(4px);
|
||||
transition: opacity 256ms ease-in;
|
||||
}
|
||||
.msgbox-box.msgbox-box-hide {
|
||||
opacity: 0;
|
||||
}
|
||||
.msgbox-box:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
.msgbox-content {
|
||||
font-size: inherit;
|
||||
padding-top: 10px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
display: flex;
|
||||
}
|
||||
.msgbox-img {
|
||||
margin-right: 5px;
|
||||
}
|
||||
.msgbox-text {
|
||||
width: 100%;
|
||||
}
|
||||
.msgbox-title {
|
||||
width: 100%;
|
||||
}
|
||||
.msgbox-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
.msgbox-command {
|
||||
padding-right: 10px;
|
||||
padding-bottom: 10px;
|
||||
display: flex;
|
||||
}
|
||||
.msgbox-close {
|
||||
color: #fff;
|
||||
font-size: inherit;
|
||||
text-decoration: none;
|
||||
margin: 0 10px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
transition: color 64ms ease-out, text-shadow 64ms ease-out;
|
||||
}
|
||||
.msgbox-close:hover {
|
||||
color: #efefef;
|
||||
text-shadow: 0 0 4px #fff;
|
||||
}
|
||||
.msgbox-close:first-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
.msgbox-close:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
@media (min-width: 481px) and (max-width: 767px) {
|
||||
.msgbox-area {
|
||||
left: 80px;
|
||||
right: 80px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.msgbox-area {
|
||||
width: 480px;
|
||||
height: 0;
|
||||
top: 15px;
|
||||
left: auto;
|
||||
right: 15px;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
{% extends "base.jinja" %}
|
||||
{% block head %}
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for(service.id +'_endpoint' + '.static', filename='notifications/css/styles.css') }}">
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{% from 'components/menu_item.jinja' import menu_item %}
|
||||
|
||||
{#
|
||||
notifications_menu - Tabs menu to navigate between the notifications screens.
|
||||
Parameters:
|
||||
- active_menuitem: Current active tab. Options: 'general', 'settings', ...
|
||||
#}
|
||||
{% macro notifications_menu(active_menuitem) -%}
|
||||
<nav class="row collapse-on-mobile">
|
||||
{{ menu_item(service.id, 'index', 'Main', active_menuitem, isLeft=true) }}
|
||||
{{ menu_item(service.id, 'settings_get', 'Settings', active_menuitem, isRight=true) }}
|
||||
<a href="javascript:void(0);" class="mobile-nav-icon" onclick="toggleMobileNav(this, `{{ url_for('static', filename='img/expand-more.svg') }}`, `{{ url_for('static', filename='img/expand-less.svg') }}`)">
|
||||
<img style="width: 36px;" src="{{ url_for('static', filename='img/expand-more.svg') }}"/>
|
||||
</a>
|
||||
</nav>
|
||||
{%- endmacro %}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{% extends "notifications/base.jinja" %}
|
||||
{% block main %}
|
||||
<img src="{{ url_for(service.id +'_endpoint' + '.static', filename=service.logo) }}" width="300"/>
|
||||
{% from 'notifications/components/notifications_menu.jinja' import notifications_menu with context %}
|
||||
{{ notifications_menu(tab) }}
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<script>
|
||||
// the following block loads a css file to the document,
|
||||
// and is therefore available whereever this jinja file is injected
|
||||
var head = document.getElementsByTagName('HEAD')[0];
|
||||
var link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.type = 'text/css';
|
||||
link.href = "{{ url_for('notifications_endpoint' + '.static', filename='notifications/css/styles.css') }}";
|
||||
head.appendChild(link);
|
||||
|
||||
// load main js file
|
||||
{% include "notifications/notifications.js" %}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
{% extends "notifications/components/notifications_tab.jinja" %}
|
||||
{% block title %}Settings{% endblock %}
|
||||
{% set tab = 'index' %}
|
||||
{% block content %}
|
||||
<style>
|
||||
.tagline {
|
||||
font-style: italic;
|
||||
font-size: 1.1em;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
padding-left: 4em;
|
||||
padding-right: 4em;
|
||||
}
|
||||
|
||||
.button_wide {
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
.button_wide:hover {
|
||||
color: white;
|
||||
}
|
||||
.big_option {
|
||||
display: inline-block;
|
||||
width: 12em;
|
||||
padding: 1em;
|
||||
vertical-align: middle;
|
||||
height: 3.5em;
|
||||
border: 4px solid var(--cmap-border);
|
||||
border-radius: 0.5em;
|
||||
background-color: var(--cmap-bg-lighter);
|
||||
font-size: 1.5em;
|
||||
margin: 1em;
|
||||
position: relative;
|
||||
}
|
||||
.big_option:hover {
|
||||
background-color: var(--cmap-bg-lightest);
|
||||
border: 4px solid var(--main-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.big_option_text {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.bonus {
|
||||
color: #999;
|
||||
font-size: 0.7em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.highlights {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.show_list_bullet {
|
||||
margin-top: 0.5em;
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
}
|
||||
.show_list_bullet li {
|
||||
text-align: left;
|
||||
list-style-type: disc;
|
||||
line-height: 1.7em;
|
||||
}
|
||||
|
||||
.plus_sign {
|
||||
font-size: 3em;
|
||||
color: #ccc;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
</style>
|
||||
<br><br>
|
||||
<div class="tagline">Advanced notifications.</div>
|
||||
This service provides advanced notifications, like javascript popups and push-style notifications.
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
// see https://coder-question.com/cq-blog/525543 and https://dev.to/takaneichinose/responsive-message-box-with-javascript-class-25ie
|
||||
class MessageBox {
|
||||
constructor(option) {
|
||||
this.option = option;
|
||||
|
||||
this.msgBoxArea = document.querySelector("#msgbox-area");
|
||||
|
||||
if (this.msgBoxArea === null) {
|
||||
this.msgBoxArea = document.createElement("DIV");
|
||||
this.msgBoxArea.setAttribute("id", "msgbox-area");
|
||||
this.msgBoxArea.classList.add("msgbox-area");
|
||||
|
||||
document.body.appendChild(this.msgBoxArea);
|
||||
}
|
||||
}
|
||||
|
||||
show(title) {
|
||||
if (title === "" || title === undefined || title === null) {
|
||||
throw "title is empty or not defined.";
|
||||
}
|
||||
|
||||
|
||||
const msgboxBox = document.createElement("DIV");
|
||||
const msgboxImage = document.createElement("DIV");
|
||||
const msgboxContent = document.createElement("DIV");
|
||||
const msgboxTextWrapper = document.createElement("DIV");
|
||||
const msgboxTitle = document.createElement("DIV");
|
||||
const msgboxText = document.createElement("DIV");
|
||||
const msgboxCommand = document.createElement("DIV");
|
||||
const msgboxClose = document.createElement("A");
|
||||
|
||||
|
||||
if (this.option.image != null){
|
||||
msgboxImage.classList.add("msgbox-img");
|
||||
var img = document.createElement("img");
|
||||
img.width = 100;
|
||||
img.src = this.option.image;
|
||||
msgboxImage.width = '100px';
|
||||
msgboxImage.appendChild(img);
|
||||
msgboxContent.appendChild(msgboxImage);
|
||||
}
|
||||
|
||||
// Content area of the message box
|
||||
msgboxContent.classList.add("msgbox-content");
|
||||
|
||||
// title and text div
|
||||
msgboxTextWrapper.classList.add("msgbox-wrapper");
|
||||
msgboxContent.appendChild(msgboxTextWrapper);
|
||||
|
||||
// title
|
||||
msgboxTitle.classList.add("msgbox-title");
|
||||
msgboxTitle.setAttribute('style', 'font-weight: bold; margin-bottom:8px;');
|
||||
msgboxTitle.innerText = title;
|
||||
msgboxTextWrapper.appendChild(msgboxTitle);
|
||||
|
||||
// Text area
|
||||
if (this.option.body != null){
|
||||
msgboxText.classList.add("msgbox-text");
|
||||
msgboxText.innerText = this.option.body;
|
||||
msgboxTextWrapper.appendChild(msgboxText);
|
||||
}
|
||||
|
||||
// Command box or the button container
|
||||
msgboxCommand.classList.add("msgbox-command");
|
||||
|
||||
// Close button of the message box
|
||||
msgboxClose.classList.add("msgbox-close");
|
||||
msgboxClose.setAttribute("href", "#");
|
||||
if (this.option.closeLabel === undefined || this.option.closeLabel === null) {
|
||||
this.option.closeLabel = "Close";
|
||||
}
|
||||
msgboxClose.innerText = this.option.closeLabel;
|
||||
|
||||
// Container of the Message Box element
|
||||
msgboxBox.classList.add("msgbox-box");
|
||||
if (this.option.backgroundColor){
|
||||
msgboxBox.style.backgroundColor = this.option.backgroundColor;
|
||||
}
|
||||
msgboxBox.setAttribute("timeout", this.option.timeout);
|
||||
msgboxBox.appendChild(msgboxContent);
|
||||
|
||||
if (this.option.hideCloseButton === false
|
||||
|| this.option.hideCloseButton === undefined) {
|
||||
// If the hideCloseButton flag is false, or if it is undefined
|
||||
|
||||
// Append the close button to the container
|
||||
msgboxCommand.appendChild(msgboxClose);
|
||||
msgboxBox.appendChild(msgboxCommand);
|
||||
}
|
||||
|
||||
this.msgBoxArea.appendChild(msgboxBox);
|
||||
|
||||
msgboxClose.onclick = (evt) => {
|
||||
evt.preventDefault();
|
||||
|
||||
if (msgboxBox.classList.contains("msgbox-box-hide")) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.msgboxTimeout);
|
||||
|
||||
this.msgboxTimeout = null;
|
||||
|
||||
this.hide(msgboxBox, this.option.onClose);
|
||||
};
|
||||
|
||||
if (this.option.timeout > 0) {
|
||||
this.msgboxTimeout = setTimeout(() => {
|
||||
this.hide(msgboxBox, this.option.onClose);
|
||||
}, this.option.timeout);
|
||||
}
|
||||
}
|
||||
|
||||
hideMessageBox(msgboxBox) {
|
||||
return new Promise(resolve => {
|
||||
msgboxBox.ontransitionend = () => {
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async hide(msgboxBox, callback) {
|
||||
if (msgboxBox !== null) {
|
||||
// If the Message Box is not yet closed
|
||||
|
||||
msgboxBox.classList.add("msgbox-box-hide");
|
||||
}
|
||||
|
||||
await this.hideMessageBox(msgboxBox);
|
||||
|
||||
this.msgBoxArea.removeChild(msgboxBox);
|
||||
|
||||
clearTimeout(this.msgboxTimeout);
|
||||
|
||||
if (typeof callback === "function") {
|
||||
// If the callback parameter is a function
|
||||
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const msgboxShowMessage = document.querySelector("#msgboxShowMessage");
|
||||
const msgboxHiddenClose = document.querySelector("#msgboxHiddenClose");
|
||||
|
||||
// Creation of Message Box class, and the sample usage
|
||||
/*
|
||||
const msgbox = new MessageBox({
|
||||
timeout: 10000,
|
||||
hideCloseButton: false
|
||||
});
|
||||
const msgboxPersistent = new MessageBox({
|
||||
timeout: 0
|
||||
});
|
||||
const msgboxNoClose = new MessageBox({
|
||||
timeout: 5000,
|
||||
hideCloseButton: true
|
||||
});
|
||||
|
||||
|
||||
*/
|
||||
|
|
@ -0,0 +1,396 @@
|
|||
{% include "notifications/message_box.js" %}
|
||||
|
||||
|
||||
var userToken = null;
|
||||
|
||||
/**
|
||||
* creating a notification from JS
|
||||
*
|
||||
* Example:
|
||||
* createNotification('this is the title', {target_uis:['js_message_box', 'webapi'], body:'body line 1\nline 2', image:'/static/img/ghost_3d.png', timeout:3000})
|
||||
*
|
||||
* Options dictionary can include:
|
||||
target_uis: list of target_uis, e.g., ['js_message_box', 'webapi'] // "default" will be replaced by default_target_ui
|
||||
notification_type: string,e.g. "information", all possibilities are: "debug", "information", "warning", "error", "exception"
|
||||
body: string
|
||||
data: any kind of data, which will be stored in the notification
|
||||
image: image url
|
||||
icon: currently only used for web_api (see https://notifications.spec.whatwg.org/#api ) , and not even on all platforms there
|
||||
timeout: [ms] after which the notification will close automatically.
|
||||
*/
|
||||
async function createNotification(title, options){
|
||||
if (!websocket){return}
|
||||
if (websocket.readyState === WebSocket.OPEN) {
|
||||
websocket.send(JSON.stringify( {'user_token': userToken, 'title':title, 'options': options}));
|
||||
} else {
|
||||
// If the socket is not open yet, retry in 1 s
|
||||
setTimeout(createNotification, 1000, title, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function onClose(id, targetUi){
|
||||
//console.log('closed message')
|
||||
createNotification('on_close', {'target_uis':['internal_notification'], 'data':{'id':id, 'target_ui':targetUi}});
|
||||
}
|
||||
|
||||
|
||||
function onShow(id, targetUi, success=true){
|
||||
//console.log('closed message')
|
||||
createNotification('on_show', {'target_uis':['internal_notification'], 'data':{'id':id, 'target_ui':targetUi, 'success':success}});
|
||||
}
|
||||
|
||||
|
||||
function setTargetUiAvailability(targetUi, isAvailable, id=null) {
|
||||
createNotification('set_target_ui_availability', {'target_uis':['internal_notification'], 'data':{'id':id, 'target_ui':targetUi, 'is_available':isAvailable}});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
var webapiHasPermission = false; //global variable
|
||||
/**
|
||||
* This function just checks if Notification_API is granted and writes the result
|
||||
* into the global variable webapiHasPermission.
|
||||
* webapiHasPermission has 3 states: true, false, "default"
|
||||
* true --> permission was given
|
||||
* false --> permission was denied
|
||||
* "default" --> no decision was made by the user
|
||||
*
|
||||
*
|
||||
* This function and the global variable is useful because then changes in
|
||||
* Notification.permission can be detected.
|
||||
*
|
||||
* @returns webapiHasPermission
|
||||
*/
|
||||
function checkWebapiHasPermission(){
|
||||
if (!("Notification" in window)) {
|
||||
return false
|
||||
}
|
||||
// Let's check whether notification permissions have already been granted
|
||||
else if (Notification.permission === "granted") {
|
||||
webapiHasPermission = true;
|
||||
} else if (Notification.permission === "denied") {
|
||||
webapiHasPermission = false;
|
||||
} else if (Notification.permission === "default") {
|
||||
// the user has not chosen anything, and the permission could be requested
|
||||
webapiHasPermission = "default";
|
||||
}
|
||||
|
||||
return webapiHasPermission
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks and if necessary requests the Notification_API permission
|
||||
* @param {*} fGranted A function that is called if fGranted == true
|
||||
* @param {*} fDenied A function that is called if fGranted == false
|
||||
* @param {*} fDefault A function that is called if fGranted == "default"
|
||||
* @param {*} retries_if_permission_default : How often should it request Notification_API permission?
|
||||
* @param {*} retry_time_distance : After how many milliseconds should it ask again for permission?
|
||||
* @returns webapiHasPermission
|
||||
*/
|
||||
function checkAndRequestWebapiPermission(fGranted, fDenied, fDefault, retries_if_permission_default=5, retry_time_distance=2000){
|
||||
if (!("Notification" in window)) {
|
||||
return false
|
||||
}
|
||||
// console.log(`checkAndRequestWebapiPermission ${retries_if_permission_default}`);
|
||||
checkWebapiHasPermission()
|
||||
|
||||
if (webapiHasPermission == true) {
|
||||
fGranted();
|
||||
} else if (webapiHasPermission == false) {
|
||||
fDenied();
|
||||
} else // if the status is undecided, then request permission
|
||||
if (webapiHasPermission == "default") {
|
||||
Notification.requestPermission().then(function (permission) {
|
||||
checkWebapiHasPermission();
|
||||
|
||||
|
||||
if (webapiHasPermission == true) {
|
||||
fGranted();
|
||||
} else if (webapiHasPermission == false) {
|
||||
fDenied();
|
||||
} else if (webapiHasPermission == "default")
|
||||
// if the status is still undecided, then request permission after retry_time_distance (by recursion)
|
||||
if (webapiHasPermission == "default") {
|
||||
// retry request permission through a recursive call:
|
||||
if (retries_if_permission_default>0){
|
||||
// create recursion loop, that breaks if user grants or blocks the notification
|
||||
// console.log(`Start recursion ${retries_if_permission_default-1}`);
|
||||
setTimeout(checkAndRequestWebapiPermission, retry_time_distance, fGranted, fDenied, fDefault, retries_if_permission_default-1, retry_time_distance);
|
||||
} else
|
||||
// if it reached the end of the recursion without any user interaction, then
|
||||
// deactivate the ui_notification
|
||||
if (retries_if_permission_default==0){
|
||||
fDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return webapiHasPermission
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Created a Notifications_API notification similar to a push-notification.
|
||||
* It also asks for permission, if possible.
|
||||
* @param {*} jsNotification
|
||||
*
|
||||
*
|
||||
In prinicple the jsNotification['options'] in can include all items listed in https://notifications.spec.whatwg.org/#api .
|
||||
they are however not all used on all platforms, see https://developer.mozilla.org/en-US/docs/Web/API/notification#browser_compatibility
|
||||
|
||||
*
|
||||
*/
|
||||
function webapiNotification(jsNotification) {
|
||||
// console.log('webapiNotification')
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
|
||||
var title = jsNotification['title'];
|
||||
var options = jsNotification['options'];
|
||||
|
||||
function createWebapiNotification(){
|
||||
let notification = new Notification(title, options);
|
||||
// see https://flaviocopes.com/notifications-api/#add-an-image
|
||||
// see https://levelup.gitconnected.com/use-the-javascript-notification-api-to-display-native-popups-43f6227b9980
|
||||
// see https://www.htmlgoodies.com/html5/creating-your-own-notifications-with-the-html5-notifications-api/
|
||||
notification.onclick = (() => {
|
||||
// not implemented yet
|
||||
});
|
||||
notification.onclose = (() => {
|
||||
onClose(jsNotification['id'], 'webapi');
|
||||
});
|
||||
notification.onshow = (() => {
|
||||
// do something
|
||||
onShow(jsNotification['id'], 'webapi');
|
||||
});
|
||||
|
||||
function closeNotification(){notification.close()}
|
||||
if (jsNotification['timeout']>0) {
|
||||
setTimeout(closeNotification, jsNotification['timeout']);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
function fGranted(){
|
||||
createWebapiNotification();
|
||||
}
|
||||
function fDenied(){
|
||||
console.debug(`Notification.requestPermission() = ${webapiHasPermission}`);
|
||||
setTargetUiAvailability('webapi', false, jsNotification['id']);
|
||||
}
|
||||
function fDefault(){
|
||||
console.debug(`Notification.requestPermission() = ${webapiHasPermission}`);
|
||||
setTargetUiAvailability('webapi', false, jsNotification['id']);
|
||||
}
|
||||
|
||||
checkAndRequestWebapiPermission(fGranted, fDenied, fDefault);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Creates a javascript popup message.
|
||||
*
|
||||
* @param {*} jsNotification
|
||||
*
|
||||
* Used options:
|
||||
- notification_type
|
||||
- body
|
||||
- image
|
||||
- timeout
|
||||
*/
|
||||
function jsMessageBox(jsNotification){
|
||||
function thisNotificationClose(){
|
||||
onClose(jsNotification['id'], 'js_message_box')
|
||||
}
|
||||
|
||||
var backgroundColor = null;
|
||||
if (jsNotification['notification_type']){
|
||||
backgroundColor = `var(--cmap-msgbox-${jsNotification['notification_type']})`
|
||||
}
|
||||
|
||||
msgbox = new MessageBox({
|
||||
body: jsNotification['options']['body'],
|
||||
image: jsNotification['options']['image'],
|
||||
timeout: jsNotification['timeout'],
|
||||
closeLabel: 'Close',
|
||||
'onClose': thisNotificationClose,
|
||||
hideCloseButton: false,
|
||||
backgroundColor: backgroundColor,
|
||||
});
|
||||
msgbox.show(jsNotification['title']);
|
||||
|
||||
onShow(jsNotification['id'], 'js_message_box');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the full jsNotification in the javascript console
|
||||
* @param {*} jsNotification
|
||||
*/
|
||||
function js_console(jsNotification){
|
||||
if (jsNotification['notification_type'] == 'error'){
|
||||
console.error(jsNotification);
|
||||
} else if (jsNotification['notification_type'] == 'exception'){
|
||||
console.error(jsNotification);
|
||||
} else if (jsNotification['notification_type'] == 'warning'){
|
||||
console.warn(jsNotification);
|
||||
} else {
|
||||
console.log(jsNotification);
|
||||
}
|
||||
onShow(jsNotification['id'], 'js_console');
|
||||
onClose(jsNotification['id'], 'js_console');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Shows the jsNotification in the targetUi
|
||||
* @param {*} targetUi
|
||||
* @param {*} jsNotification
|
||||
*
|
||||
*
|
||||
*
|
||||
jsNotification is the data structure usually created in python by notification.to_js_notification()
|
||||
An example jsNotification looks like:
|
||||
{
|
||||
"title": title,
|
||||
"id": id,
|
||||
"notification_type": notification_type,
|
||||
"timeout": timeout,
|
||||
"options": {
|
||||
body = "",
|
||||
image = None,
|
||||
},
|
||||
}
|
||||
|
||||
In prinicple the options dict can include all items listed in https://notifications.spec.whatwg.org/#api .
|
||||
- Only some of the are used in js_message_box (see jsMessageBox),
|
||||
and even in web_api they are not all used on all platforms, see https://developer.mozilla.org/en-US/docs/Web/API/notification#browser_compatibility
|
||||
- js_logging will display the entire jsNotification data structure.
|
||||
|
||||
*/
|
||||
async function show_notification(targetUi, jsNotification){
|
||||
if (targetUi == 'js_message_box'){
|
||||
jsMessageBox(jsNotification);
|
||||
} else if (targetUi == 'webapi'){
|
||||
webapiNotification(jsNotification);
|
||||
} else if (targetUi == 'js_console'){
|
||||
js_console(jsNotification);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Check the status of Notification_API permission call
|
||||
* setTargetUiAvailability to activate or deactivate the WebAPINotifications
|
||||
* This allows to activate the e.g. webapi again, if the user first denied it, and then allowed it again.
|
||||
*/
|
||||
async function sendUpdatedWebapiPermission(){
|
||||
var beforeWebapiHasPermission = webapiHasPermission;
|
||||
var newWebapiHasPermission = checkWebapiHasPermission();
|
||||
|
||||
// if something has changed
|
||||
if (beforeWebapiHasPermission != newWebapiHasPermission){
|
||||
|
||||
|
||||
// case: webapi was deactivated, but now is "granted"
|
||||
if ((newWebapiHasPermission == 'default')|| (newWebapiHasPermission == true)){
|
||||
setTargetUiAvailability('webapi', true);
|
||||
console.log('Activating webapi');
|
||||
}
|
||||
// case: webapi was active, but now is "denied"
|
||||
else if (newWebapiHasPermission == false) {
|
||||
setTargetUiAvailability('webapi', false);
|
||||
console.log('Deactivating webapi');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
var websocket = null;
|
||||
|
||||
function connectWebsocket() {
|
||||
// get necessary info for the opening of the websocket
|
||||
send_request("{{ url_for('notifications_endpoint.get_websockets_info') }}", 'GET', "{{ csrf_token() }}").then(function (websocketsInfo) {
|
||||
// Create the websocket
|
||||
var url = "{{ url_for('notifications_endpoint.websocket') }}";
|
||||
console.log(url)
|
||||
userToken = websocketsInfo['user_token'];
|
||||
websocket = new WebSocket(url);
|
||||
|
||||
// Authenticate and add listeners when the websocket connection is open
|
||||
websocket.onopen = function(e) {
|
||||
// Sends a message to the server, that does nothing, but enables the server to register the user_token to the websocket_client
|
||||
websocket.send(JSON.stringify({'user_token': userToken, "title": 'IGNORE_NOTIFICATION_TITLE'}));
|
||||
|
||||
// set the api_permissions
|
||||
sendUpdatedWebapiPermission()
|
||||
setInterval(sendUpdatedWebapiPermission, 3000);
|
||||
|
||||
console.debug(`Websocket connection to ${url} is open`);
|
||||
};
|
||||
|
||||
websocket.onmessage = function(message) {
|
||||
var jsNotification = null;
|
||||
try{
|
||||
jsNotification = JSON.parse(message.data);
|
||||
} catch(e) {
|
||||
console.warn(`Json could not be parsed: ` + e.message)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var targetUis = jsNotification["options"]['target_uis'];
|
||||
for (let i in targetUis) {
|
||||
show_notification(targetUis[i], jsNotification);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onclose = function(e) {
|
||||
console.debug('Websocket was closed. Reconnect will be attempted in 10 seconds.', e.reason);
|
||||
setTimeout(function() {
|
||||
connectWebsocket();
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
websocket.onerror = function(err) {
|
||||
console.error('Socket encountered error: ', err.message);
|
||||
// websocket.close();
|
||||
};
|
||||
|
||||
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.error('Connection to the websocket failed. Reconnect will be attempted in 10 seconds.', error.reason);
|
||||
setTimeout(function() {
|
||||
connectWebsocket();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* If a user is logged in then regularly check if the webapi notification permission was changed
|
||||
*/
|
||||
if ('{{ current_user.username }}'){
|
||||
connectWebsocket()
|
||||
}else{
|
||||
// no user logged in
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
{% extends "notifications/components/notifications_tab.jinja" %}
|
||||
{% block title %}Settings{% endblock %}
|
||||
{% set tab = 'settings_get' %}
|
||||
{% block content %}
|
||||
<br/>
|
||||
<style>
|
||||
.big_option {
|
||||
display: inline-block;
|
||||
width: 14em;
|
||||
padding: 1em;
|
||||
vertical-align: middle;
|
||||
height: 3.5em;
|
||||
border: 4px solid var(--cmap-border);
|
||||
border-radius: 0.5em;
|
||||
background-color: var(--cmap-bg-lighter);
|
||||
font-size: 1.5em;
|
||||
margin: 1em;
|
||||
position: relative;
|
||||
|
||||
}
|
||||
.big_option:hover {
|
||||
background-color: var(--cmap-bg-lightest);
|
||||
border: 4px solid var(--main-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.big_option_text {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.instructions {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
{# TODO: End Remove #}
|
||||
|
||||
.css-1rbd7t8 {
|
||||
box-sizing: border-box;
|
||||
margin: 60px 0px 24px;
|
||||
min-width: 0px;
|
||||
}
|
||||
.css-yn7azs {
|
||||
box-sizing: border-box;
|
||||
margin: 0px;
|
||||
min-width: 0px;
|
||||
-moz-box-pack: justify;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
font-family: "Source Serif Pro", serif;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant: common-ligatures;
|
||||
text-rendering: optimizelegibility;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="card">
|
||||
<h1>{{ _("Configure your extension") }}</h1>
|
||||
<br>
|
||||
|
||||
<div class="note">
|
||||
{{ _("You can disable the notifications.") }}<br/>
|
||||
</div>
|
||||
<br/>
|
||||
|
||||
<form action="{{ url_for(service.get_blueprint_name() + '.settings_post') }}" method="POST" role="form">
|
||||
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div>Activate Notifications:</div>
|
||||
<select name="show_menu">
|
||||
<option value="yes" {% if show_menu == 'yes' %}selected{% endif %}>Yes</option>
|
||||
<option value="no" {% if show_menu == 'no' %}selected{% endif %}>No</option>
|
||||
</select>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<button type="submit" class="btn">{{ _("Save") }}</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
234
src/cryptoadvance/specterext/notifications/ui_notifications.py
Normal file
234
src/cryptoadvance/specterext/notifications/ui_notifications.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .notifications import NotificationTypes
|
||||
from flask import flash
|
||||
|
||||
|
||||
class BaseUINotifications:
|
||||
"A base class defining functions that every user faced UI Notification display system should have"
|
||||
|
||||
def __init__(self, on_close=None, on_show=None):
|
||||
self.compatible_notification_types = {
|
||||
NotificationTypes.debug,
|
||||
NotificationTypes.information,
|
||||
NotificationTypes.warning,
|
||||
NotificationTypes.error,
|
||||
NotificationTypes.exception,
|
||||
}
|
||||
self.name = "base"
|
||||
self.is_available = True
|
||||
self.user_id = None
|
||||
self.callable_from_any_session = True
|
||||
self.on_close = on_close
|
||||
self.on_show = on_show
|
||||
|
||||
def show(self, notification):
|
||||
pass
|
||||
|
||||
def __repr__(self):
|
||||
return str({"class name": self.__class__.__name__, "attributes": self.__dict__})
|
||||
|
||||
|
||||
class PrintNotifications(BaseUINotifications):
|
||||
"""
|
||||
Notifications are shown in print()
|
||||
|
||||
Callback functions are:
|
||||
- on_close(notification_id, target_ui), should be assigned to delete the message (called immediately after showing)
|
||||
- on_show(notification_id, target_ui) (called immediately after showing)
|
||||
"""
|
||||
|
||||
def __init__(self, on_close=None, on_show=None):
|
||||
super().__init__(on_close=on_close, on_show=on_show)
|
||||
self.name = "print"
|
||||
|
||||
def show(self, notification):
|
||||
if (
|
||||
not self.is_available
|
||||
or notification.notification_type not in self.compatible_notification_types
|
||||
):
|
||||
return
|
||||
print(notification)
|
||||
|
||||
notification.set_shown(self.name)
|
||||
if self.on_show:
|
||||
self.on_show(notification.id, self.name)
|
||||
notification.set_closed(self.name)
|
||||
if self.on_close:
|
||||
self.on_close(notification.id, self.name)
|
||||
return True # successfully broadcasted
|
||||
|
||||
|
||||
class LoggingNotifications(BaseUINotifications):
|
||||
"""
|
||||
Notifications are shown in logger.info()
|
||||
|
||||
Callback functions are:
|
||||
- on_close(notification_id, target_ui), should be assigned to delete the message (called immediately after showing)
|
||||
- on_show(notification_id, target_ui) (called immediately after showing)
|
||||
"""
|
||||
|
||||
def __init__(self, on_close=None, on_show=None):
|
||||
super().__init__(on_close=on_close, on_show=on_show)
|
||||
self.name = "logging"
|
||||
|
||||
def show(self, notification):
|
||||
if (
|
||||
not self.is_available
|
||||
or notification.notification_type not in self.compatible_notification_types
|
||||
):
|
||||
return
|
||||
|
||||
logger_func = logger.info
|
||||
if notification.notification_type == NotificationTypes.debug:
|
||||
logger_func = logger.debug
|
||||
elif notification.notification_type == NotificationTypes.information:
|
||||
logger_func = logger.info
|
||||
elif notification.notification_type == NotificationTypes.warning:
|
||||
logger_func = logger.warning
|
||||
elif notification.notification_type == NotificationTypes.error:
|
||||
logger_func = logger.error
|
||||
elif notification.notification_type == NotificationTypes.exception:
|
||||
logger_func = logger.exception
|
||||
|
||||
logger_func(
|
||||
notification.to_text(),
|
||||
exc_info=notification.notification_type
|
||||
in {NotificationTypes.error, NotificationTypes.exception},
|
||||
)
|
||||
notification.set_shown(self.name)
|
||||
if self.on_show:
|
||||
self.on_show(notification.id, self.name)
|
||||
notification.set_closed(self.name)
|
||||
if self.on_close:
|
||||
self.on_close(notification.id, self.name)
|
||||
return True # successfully broadcasted
|
||||
|
||||
|
||||
class FlashNotifications(BaseUINotifications):
|
||||
"""
|
||||
Flask flash notifications. They only appears after render_template is called.
|
||||
|
||||
Flask flash are dependent on the current flask.session. This prevents flash and with it FlashNotifications.show
|
||||
from showing up in the current session, if called from outside the current session.
|
||||
|
||||
Example what will work (because of the same flask.session):
|
||||
- and endpoint is called triggering flash or FlashNotifications.show
|
||||
|
||||
Example what will NOT work:
|
||||
- flash or FlashNotifications.show is called from via a websocket connection, e.g.,
|
||||
createNotification('flash title', {target_uis:'flash'})
|
||||
The websocket connection does not have access to the current flask.session.
|
||||
|
||||
Callback functions are:
|
||||
- on_close(notification_id, target_ui), should be assigned to delete the message (called immediately after showing)
|
||||
- on_show(notification_id, target_ui) (called immediately after showing)
|
||||
"""
|
||||
|
||||
def __init__(self, on_close=None, on_show=None):
|
||||
super().__init__(on_close=on_close, on_show=on_show)
|
||||
self.compatible_notification_types = {
|
||||
NotificationTypes.information,
|
||||
NotificationTypes.warning,
|
||||
NotificationTypes.error,
|
||||
NotificationTypes.exception,
|
||||
}
|
||||
self.name = "flash"
|
||||
|
||||
# Flash notifications will not be used as a fallback UINotification,
|
||||
# because they are not callable_from_any_session.
|
||||
self.callable_from_any_session = False
|
||||
|
||||
def show(self, notification):
|
||||
if (
|
||||
not self.is_available
|
||||
or notification.notification_type not in self.compatible_notification_types
|
||||
):
|
||||
return
|
||||
flash(
|
||||
notification.to_text(),
|
||||
notification.notification_type,
|
||||
)
|
||||
notification.set_shown(self.name)
|
||||
if self.on_show:
|
||||
self.on_show(notification.id, self.name)
|
||||
notification.set_closed(self.name)
|
||||
if self.on_close:
|
||||
self.on_close(notification.id, self.name)
|
||||
return True # successfully broadcasted
|
||||
|
||||
|
||||
class JSConsoleNotifications(BaseUINotifications):
|
||||
"""
|
||||
Shows the notifications in the javascript console. The logic is mostly in notifications.js
|
||||
|
||||
Callback functions are:
|
||||
- on_close(notification_id, target_ui), should be assigned to delete the message
|
||||
- on_show(notification_id, target_ui)
|
||||
"""
|
||||
|
||||
def __init__(self, user_id, websockets_client, on_close=None, on_show=None):
|
||||
super().__init__(on_close=on_close, on_show=on_show)
|
||||
if not websockets_client:
|
||||
raise Exception("websockets_client not set")
|
||||
self.name = "js_console"
|
||||
self.user_id = user_id
|
||||
self.websockets_client = websockets_client
|
||||
|
||||
def show(self, notification):
|
||||
if (
|
||||
not self.is_available
|
||||
or notification.notification_type not in self.compatible_notification_types
|
||||
):
|
||||
return
|
||||
|
||||
# convert to json object and set the target_ui as only self.name.
|
||||
# The Notification manager handles sending to other target_uis
|
||||
js_notification = notification.to_js_notification()
|
||||
js_notification["options"]["target_uis"] = [self.name]
|
||||
if not self.websockets_client:
|
||||
logger.info(
|
||||
f"{self.__class__.__name__}.websockets_client of user {self.user_id} returned {self.websockets_client} --> Setting is_available = false. "
|
||||
)
|
||||
self.is_available = False
|
||||
return False
|
||||
self.websockets_client.send(js_notification)
|
||||
return True # successfully broadcasted
|
||||
|
||||
|
||||
class JSNotifications(JSConsoleNotifications):
|
||||
"""
|
||||
A javascript message box. The logic is mostly in notifications.js
|
||||
|
||||
Callback functions are:
|
||||
- on_close(notification_id, target_ui), should be assigned to delete the message
|
||||
- on_show(notification_id, target_ui)
|
||||
"""
|
||||
|
||||
def __init__(self, user_id, websockets_client, on_close=None, on_show=None):
|
||||
super().__init__(user_id, websockets_client, on_close=on_close, on_show=on_show)
|
||||
self.compatible_notification_types = {
|
||||
NotificationTypes.information,
|
||||
NotificationTypes.warning,
|
||||
NotificationTypes.error,
|
||||
NotificationTypes.exception,
|
||||
}
|
||||
self.name = "js_message_box"
|
||||
|
||||
|
||||
class WebAPINotifications(JSNotifications):
|
||||
"""
|
||||
Calls push-notification-style notification, that is realized via Notification_API
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
|
||||
|
||||
|
||||
Callback functions are:
|
||||
- on_close(notification_id, target_ui), should be assigned to delete the message
|
||||
- on_show(notification_id, target_ui)
|
||||
"""
|
||||
|
||||
def __init__(self, user_id, websockets_client, on_close=None, on_show=None):
|
||||
super().__init__(user_id, websockets_client, on_close=on_close, on_show=on_show)
|
||||
self.name = "webapi" # see https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
"""
|
||||
This file enabled to keep an open websocket connection with the browser sessions.
|
||||
"""
|
||||
import logging, threading, time, secrets
|
||||
import time, json
|
||||
from cryptoadvance.specter.util.common import robust_json_dumps
|
||||
import simple_websocket, ssl
|
||||
from cryptoadvance.specter.specter_error import SpecterError
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
IGNORE_NOTIFICATION_TITLE = "IGNORE_NOTIFICATION_TITLE"
|
||||
|
||||
|
||||
class WebsocketServer:
|
||||
"""
|
||||
A forever lived websockets server in a different thread.
|
||||
The server has 2 main functions:
|
||||
1. Recieve messages from webbrowser websocket connections and call notification_manager.create_and_show
|
||||
2. Recieve messages (notifications) from python websocket connection (broadcaster) and send them to the webbrowser websocket connections
|
||||
Each message must contain a user_token, which is checked against user_manager.user.websocket_token to make sure this is a legitimate user.
|
||||
Otherwise the user_token will not be found in user_manager.user.websocket_token and rejected.
|
||||
Before the python websocket connection is established, the set_as_broadcaster method should be called to inform self that this user_token will be a broadcaster
|
||||
1. Javascript creates a message
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ │ websocket.send │ │
|
||||
│ Browser javascript ├─────────────────────────► │ WebsocketsServer │
|
||||
│ │ │ │
|
||||
└───────────────────────┘ └───────────┬───────────┘
|
||||
│
|
||||
│ notification_manager.create_and_show
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ │
|
||||
│ NotificationManager │
|
||||
│ │
|
||||
└───────────────────────┘
|
||||
2. A UI_Notification creates a message for the browser to show
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ │ websockets_client.send │ │
|
||||
│ JSConsoleNotifications├─────────────────────────► │ WebsocketsClient │
|
||||
│ │ │ │
|
||||
└───────────────────────┘ └───────────┬───────────┘
|
||||
│
|
||||
│
|
||||
│ websockets_client.send
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ │ │ │
|
||||
│ Browser javascript │ websocket.send │ │
|
||||
│ │◄──────────────────────────┤ WebsocketsServer │
|
||||
│ websocket.on_message │ │ │
|
||||
│ │ │ │
|
||||
└───────────────────────┘ └───────────────────────┘
|
||||
"""
|
||||
|
||||
def __init__(self, notification_manager, verbose_debug=False):
|
||||
logger.info(f"Create {self.__class__.__name__}")
|
||||
|
||||
# a broadcaster has special rights, and can send potentially harmful messages to the websocket server,
|
||||
# such at a Notification("quit_server"), which will quit the server
|
||||
# It is also the only connection which makes the server send notifications to other websocket connections
|
||||
self.broadcaster_tokens = list()
|
||||
# self.connections matches user_tokens to websocket connections, such that a
|
||||
# Notification can be sent to all websocket connections that are associated to this user_tokens
|
||||
self.connections = list()
|
||||
self.notification_manager = notification_manager
|
||||
self.verbose_debug = verbose_debug
|
||||
|
||||
def __str__(self):
|
||||
return str(self.__dict__)
|
||||
|
||||
def get_broadcaster_tokens(self):
|
||||
return [d["user_token"] for d in self.broadcaster_tokens]
|
||||
|
||||
def _get_connection_dict_of_websocket(self, websocket):
|
||||
for d in self.connections:
|
||||
if d["websocket"] == websocket:
|
||||
return d
|
||||
return None
|
||||
|
||||
def get_token_of_websocket(self, websocket):
|
||||
connection_dict = self._get_connection_dict_of_websocket(websocket)
|
||||
if not connection_dict:
|
||||
logger.warning(f"user_token of websocket {websocket} could not be found.")
|
||||
return
|
||||
return connection_dict["user_token"]
|
||||
|
||||
def get_connections_by_token(self, user_token):
|
||||
connections = []
|
||||
for d in self.connections:
|
||||
if d["user_token"] == user_token:
|
||||
connections.append(d["websocket"])
|
||||
return connections
|
||||
|
||||
def get_user_of_user_token(self, user_token):
|
||||
for (
|
||||
known_username,
|
||||
known_token,
|
||||
) in self.notification_manager.websocket_tokens.items():
|
||||
if known_token == user_token:
|
||||
return known_username
|
||||
return None
|
||||
|
||||
def set_as_broadcaster(self, user_token):
|
||||
new_entry = {"user_token": user_token}
|
||||
logger.debug(f"set_as_broadcaster {f'...{user_token[-5:]}'}")
|
||||
self.broadcaster_tokens.append(new_entry)
|
||||
|
||||
def remove_broadcaster(self, user_token):
|
||||
logger.debug(f"remove_broadcaster ...{user_token[-5:]}")
|
||||
self.broadcaster_tokens = [
|
||||
d for d in self.broadcaster_tokens if d["user_token"] != user_token
|
||||
]
|
||||
|
||||
def _register(self, user_token, websocket):
|
||||
if not user_token:
|
||||
logger.warning(f"no user_token given")
|
||||
return
|
||||
|
||||
d = {
|
||||
"user_token": user_token,
|
||||
"websocket": websocket,
|
||||
"opening_time": datetime.now(),
|
||||
}
|
||||
|
||||
# check if this connection exists already
|
||||
connection_dict = self._get_connection_dict_of_websocket(websocket)
|
||||
if connection_dict and connection_dict["user_token"] == user_token:
|
||||
# no need to add the connection multiple times if (websocket, user_token) are identical
|
||||
return
|
||||
|
||||
if user_token in self.get_broadcaster_tokens():
|
||||
logger.info(
|
||||
f"python-websocket-client --> python-websocket-server was first used and registered."
|
||||
)
|
||||
else:
|
||||
user = self.get_user_of_user_token(user_token)
|
||||
# If it is not a broadcaster AND the token is unknown, then reject connection
|
||||
if not user:
|
||||
logger.warning(f"user_token ...{user_token[-5:]} not found in users")
|
||||
return
|
||||
logger.info(
|
||||
f"python-websocket-server --> javascript websocket-client for flask user '{user}' was first used and registered."
|
||||
)
|
||||
|
||||
self.connections.append(d)
|
||||
if self.verbose_debug:
|
||||
logger.debug(self.connection_report())
|
||||
|
||||
def _unregister(self, websocket):
|
||||
connection_dict = self._get_connection_dict_of_websocket(websocket)
|
||||
if not connection_dict:
|
||||
logger.warning(
|
||||
f"_unregister failed, because {websocket} could not be found in self.connections."
|
||||
)
|
||||
return
|
||||
user_token = connection_dict["user_token"]
|
||||
user = self.get_user_of_user_token(user_token)
|
||||
|
||||
username = (
|
||||
user
|
||||
if user
|
||||
else (
|
||||
"Python broadcaster Client"
|
||||
if user_token in self.get_broadcaster_tokens()
|
||||
else "unknown"
|
||||
)
|
||||
)
|
||||
self.connections = [d for d in self.connections if d["websocket"] != websocket]
|
||||
logger.debug(
|
||||
f"Unregistered {websocket} belonging to {username}, started at {connection_dict['opening_time']}"
|
||||
)
|
||||
if self.verbose_debug:
|
||||
logger.debug(self.connection_report())
|
||||
|
||||
def connection_report(self):
|
||||
s = f"{len(self.connections)} open connections:\n"
|
||||
for i, connection_dict in enumerate(self.connections):
|
||||
simplified_dict = connection_dict.copy()
|
||||
simplified_dict["opening_time"] = connection_dict[
|
||||
"opening_time"
|
||||
].isoformat()
|
||||
simplified_dict["user_token"] = f"...{connection_dict['user_token'][-5:]}"
|
||||
simplified_dict["user"] = (
|
||||
None
|
||||
if connection_dict["user_token"] in self.get_broadcaster_tokens()
|
||||
else self.get_user_of_user_token(connection_dict["user_token"])
|
||||
)
|
||||
simplified_dict["broadcaster"] = (
|
||||
connection_dict["user_token"] in self.get_broadcaster_tokens()
|
||||
)
|
||||
s += f"{i}: {simplified_dict}\n"
|
||||
return s
|
||||
|
||||
def serve(self, environ, ping_interval=30):
|
||||
"""
|
||||
Start a server. This is an endless loop.
|
||||
It will automatically detect and close unresponsive connections.
|
||||
|
||||
Args:
|
||||
environ (_type_): a flask/werkzeug environ
|
||||
"""
|
||||
# ping_interval!=None ensures closing of connections where there is no counterpart any more
|
||||
websocket = simple_websocket.Server(environ, ping_interval=ping_interval)
|
||||
try:
|
||||
logger.info(
|
||||
f"Started websocket connection {websocket} between the server and a new client"
|
||||
)
|
||||
while True:
|
||||
# timeout is needed here otherwise the simple_websocket.Server ping_interval does not work
|
||||
data = websocket.receive(timeout=ping_interval)
|
||||
# If the timeout was triggered then data=None, and it should just restart receiving
|
||||
if not data:
|
||||
continue
|
||||
try:
|
||||
message_dictionary = json.loads(data)
|
||||
except:
|
||||
logger.warning(f"Could not decode the json data in {data}")
|
||||
continue
|
||||
|
||||
self._register(message_dictionary.get("user_token"), websocket)
|
||||
|
||||
preprocessed_instruction = self._preprocess(message_dictionary)
|
||||
if preprocessed_instruction == "quit":
|
||||
logger.debug("quit_server was called.")
|
||||
break
|
||||
elif preprocessed_instruction == "continue":
|
||||
continue
|
||||
|
||||
self._process_incoming_message(message_dictionary)
|
||||
except simple_websocket.ConnectionClosed:
|
||||
logger.info(f"Websocket connection {websocket} closed")
|
||||
finally:
|
||||
self._unregister(websocket)
|
||||
|
||||
return ""
|
||||
|
||||
def _preprocess(self, message_dictionary):
|
||||
"""
|
||||
Processes special commands to manipulate the server.
|
||||
A title 'quit_server' sent from and broadcaster can make the websocket connection close.
|
||||
"""
|
||||
user_token = message_dictionary.get("user_token")
|
||||
# if there was no user_token given, then prevent any further action with this message
|
||||
if not user_token:
|
||||
logger.warning(
|
||||
f"Notification {message_dictionary} did not contain a user_token. Disregarding notification."
|
||||
)
|
||||
return "continue"
|
||||
if message_dictionary.get("title") == IGNORE_NOTIFICATION_TITLE:
|
||||
return "continue"
|
||||
if message_dictionary.get("title") == "quit_server":
|
||||
# Accept the command from an broadcaster, but disregard the command from a user
|
||||
return "quit" if user_token in self.get_broadcaster_tokens() else "continue"
|
||||
|
||||
def _process_incoming_message(self, message_dictionary):
|
||||
"""
|
||||
This listens to messages. They can come from connections with and without broadcaster tokens.
|
||||
If this is a websocket authentication, it will so self._register,
|
||||
otherwise just forward to to the notification_manager via self._create_notification
|
||||
"""
|
||||
|
||||
user_token = message_dictionary.get("user_token")
|
||||
user = self.get_user_of_user_token(user_token)
|
||||
|
||||
if user_token in self.get_broadcaster_tokens():
|
||||
if self.verbose_debug:
|
||||
logger.debug(
|
||||
f"message from user with broadcaster_token recieved. Sending to websockets"
|
||||
)
|
||||
self._send_to_websockets(message_dictionary, user_token)
|
||||
elif user:
|
||||
if self.verbose_debug:
|
||||
logger.debug(f"message from user recieved. Creating Notification")
|
||||
self._create_notification(message_dictionary, user)
|
||||
else:
|
||||
logger.warning(
|
||||
f"user_token ...{user_token[-5:]} is not valid. Please provide a user_token in the message"
|
||||
)
|
||||
|
||||
def _create_notification(self, message_dictionary, user):
|
||||
"""Creates a notification based on the title, options contained in message_dictionary
|
||||
Example of message_dictionary:
|
||||
|
||||
{
|
||||
'title' : title,
|
||||
'options':{
|
||||
'timeout' : timeout,
|
||||
'notification_type' : notification_type,
|
||||
'target_uis' : target_uis,
|
||||
'body' : body,
|
||||
'image' : image_url,
|
||||
'icon' : icon,
|
||||
}
|
||||
}
|
||||
Only 'title' is mandatory
|
||||
The options are the optional arguments of Notification()
|
||||
"""
|
||||
if "title" not in message_dictionary:
|
||||
logger.warning(f"No title in {message_dictionary}")
|
||||
return
|
||||
|
||||
title = message_dictionary["title"]
|
||||
options = message_dictionary.get("options", {})
|
||||
|
||||
if self.verbose_debug:
|
||||
logger.debug(
|
||||
f"_create_notification with title {title}, user {user, type(user)} and options {options}"
|
||||
)
|
||||
|
||||
notification = self.notification_manager.create_and_show(
|
||||
title,
|
||||
user,
|
||||
**options,
|
||||
)
|
||||
return notification
|
||||
|
||||
def _send(self, websocket, message_dictionary):
|
||||
"Starts a new thread and sends the data to websocket"
|
||||
|
||||
def target():
|
||||
try:
|
||||
if self.verbose_debug:
|
||||
logger.debug(
|
||||
f"_send_to_websockets {websocket} message: {message_dictionary}"
|
||||
)
|
||||
websocket.send(robust_json_dumps(message_dictionary))
|
||||
except simple_websocket.ConnectionClosed:
|
||||
self._unregister(websocket)
|
||||
|
||||
thread = threading.Thread(target=target)
|
||||
thread.daemon = True # die when the main thread dies
|
||||
thread.start()
|
||||
thread.join()
|
||||
|
||||
def _send_to_websockets(self, message_dictionary, broadcaster_token):
|
||||
"""
|
||||
This sends out messages to the connected websockets, which are associated with message_dictionary['options']['user_id']
|
||||
This method shall only called by a broadcaster
|
||||
"""
|
||||
assert broadcaster_token in self.get_broadcaster_tokens()
|
||||
|
||||
if not message_dictionary["options"]["user_id"]:
|
||||
logger.warning("No options.user_id given")
|
||||
return
|
||||
|
||||
user_token = self.notification_manager.get_websocket_token(
|
||||
message_dictionary["options"]["user_id"]
|
||||
)
|
||||
connections = self.get_connections_by_token(user_token)
|
||||
if not connections:
|
||||
logger.warning(
|
||||
f"No websocket for this recipient_user.websocket_token could be found"
|
||||
)
|
||||
return
|
||||
for websocket in connections:
|
||||
self._send(websocket, message_dictionary)
|
||||
|
||||
|
||||
class WebsocketClient:
|
||||
"""
|
||||
Connects to the WebsocketServer; and then the self.send method can be used to send messages to the WebsocketServer
|
||||
|
||||
|
||||
To ensure this client is allowed to broadcast notifications to all
|
||||
websocket connections of the server we need to set
|
||||
websockets_server.set_as_broadcaster(websockets_client.user_token)
|
||||
"""
|
||||
|
||||
def __init__(self, host, port, path, ssl_cert, ssl_key, verbose_debug=False):
|
||||
self.protocol = "wss" if ssl_cert and ssl_cert else "ws"
|
||||
self.url = f"{self.protocol}://{host}:{port}/{path}"
|
||||
self.user_token = secrets.token_urlsafe(128)
|
||||
|
||||
self.verbose_debug = verbose_debug
|
||||
self.ssl_context = None
|
||||
if ssl_cert and ssl_key:
|
||||
self.ssl_context = ssl._create_unverified_context(ssl.PROTOCOL_TLS_CLIENT)
|
||||
# see https://pythontic.com/ssl/sslcontext/load_cert_chain
|
||||
self.ssl_context.load_cert_chain(certfile=ssl_cert, keyfile=ssl_key)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.__dict__)
|
||||
|
||||
def send(self, message_dictionary):
|
||||
message_dictionary["user_token"] = self.user_token
|
||||
if self.verbose_debug:
|
||||
logger.debug(f"{self.__class__.__name__} sending {message_dictionary}")
|
||||
self.websocket.send(robust_json_dumps(message_dictionary))
|
||||
|
||||
def start_client_server_in_other_thread(self):
|
||||
delay = 0.1
|
||||
success = False
|
||||
retries = 100
|
||||
for i in range(retries):
|
||||
try:
|
||||
# if successfull this process will run forever. This is why this should run in a dedicated thread.
|
||||
# Only for the first connection, this will not block this thread.
|
||||
# In practice it doesn't make any difference, as long as this function is run in a separate thread.
|
||||
logger.info(f"Connecting {self.__class__.__name__}")
|
||||
self.websocket = simple_websocket.Client(
|
||||
self.url, ssl_context=self.ssl_context
|
||||
)
|
||||
success = True
|
||||
logger.debug(
|
||||
f"Created {self.__class__.__name__} connection to url {self.url}"
|
||||
)
|
||||
self._initialize_connection_to_server()
|
||||
break
|
||||
except ConnectionRefusedError:
|
||||
logger.debug(
|
||||
f"Connection of {self.__class__.__name__} to websocket-server failed in loop {i}. Retrying in {delay}s..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
if not success:
|
||||
logger.error(
|
||||
f"Connection of {self.__class__.__name__} to websocket-server failed despite {retries} attempts."
|
||||
f"\nConfiguration: {self}"
|
||||
)
|
||||
|
||||
def start(self):
|
||||
self.thread = threading.Thread(target=self.start_client_server_in_other_thread)
|
||||
self.thread.daemon = True # die when the main thread dies
|
||||
self.thread.start()
|
||||
|
||||
def is_connected(self):
|
||||
return bool(self.websocket)
|
||||
|
||||
def _close(self):
|
||||
self.websocket.close()
|
||||
self.websocket = None
|
||||
|
||||
def quit_server(self):
|
||||
"Sends the command 'quit_server' to the websocket server. which then shuts down the connection."
|
||||
message_dictionary = {"title": "quit_server"}
|
||||
self.send(message_dictionary)
|
||||
self._close()
|
||||
|
||||
def _initialize_connection_to_server(self):
|
||||
"Sends a message to the server, that does nothing, but enables the server to register the user_token to the websocket_client"
|
||||
self.send({"title": IGNORE_NOTIFICATION_TITLE})
|
||||
|
|
@ -8,6 +8,7 @@ from cryptoadvance.specter.cli import server
|
|||
from mock import MagicMock, call, patch
|
||||
|
||||
mock_config_dict = {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": "123",
|
||||
"DEBUG": "WURSTBROT",
|
||||
"SPECTER_SSL_CERT_SUBJECT_C": "AT",
|
||||
|
|
@ -139,6 +140,7 @@ def test_server_config(init_app, create_app, caplog):
|
|||
mock_app = MagicMock()
|
||||
mock_app.config = MagicMock()
|
||||
d = {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": "123",
|
||||
"DEBUG": "WURSTBROT",
|
||||
"SPECTER_SSL_CERT_SUBJECT_C": "AT",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ def test_ServiceManager_get_service_packages(caplog):
|
|||
# This needs to be adjusted with each new extension
|
||||
# We don't need to assert every single package but we also ensure with that, that we don't
|
||||
# loose anything on the way of changing something in the service_manager
|
||||
assert len(packages) == 28
|
||||
assert len(packages) == 31
|
||||
|
||||
|
||||
def test_ServiceManager_make_path_relative(caplog):
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from cryptoadvance.specter.devices.bitcoin_core import BitcoinCore
|
||||
|
|
@ -21,6 +21,7 @@ from cryptoadvance.specter.util.wallet_importer import WalletImporter
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@patch("cryptoadvance.specter.util.wallet_importer.flash", print)
|
||||
@pytest.mark.slow
|
||||
def test_WalletManager(
|
||||
request,
|
||||
|
|
|
|||
152
tests/test_notifications.py
Normal file
152
tests/test_notifications.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import logging
|
||||
from mock import MagicMock, call, patch
|
||||
import pytest
|
||||
from cryptoadvance.specter.specter import Specter, UserManager
|
||||
from cryptoadvance.specterext.notifications.notification_manager import (
|
||||
NotificationManager,
|
||||
)
|
||||
import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def specter_with_user(empty_data_folder):
|
||||
"""This assumes a bitcoin-testnet-node is running on loalhost"""
|
||||
|
||||
specter = Specter(data_folder=empty_data_folder)
|
||||
user_manager = UserManager(specter=specter)
|
||||
|
||||
password = "somepassword"
|
||||
user_id = "someuser"
|
||||
username = "someuser"
|
||||
config = {}
|
||||
|
||||
user = user_manager.create_user(
|
||||
user_id=user_id, username=username, plaintext_password=password, config=config
|
||||
)
|
||||
yield specter
|
||||
|
||||
|
||||
def test_sending_logging_notification(specter_with_user: Specter, caplog):
|
||||
|
||||
notification_manager = NotificationManager(
|
||||
host="localhost",
|
||||
port="1234",
|
||||
ssl_cert=None,
|
||||
ssl_key=None,
|
||||
enable_websockets=False,
|
||||
)
|
||||
|
||||
# do not take the admin user but the "someuser", just to make sure "someuser" can use the default_ui_notifications
|
||||
user = specter_with_user.user_manager.get_user("someuser")
|
||||
ui_notifications_of_user = notification_manager._get_ui_notifications_of_user(
|
||||
user.username
|
||||
)
|
||||
|
||||
# _register_default_ui_notifications should have created 3 ui_notifications accessible for all users
|
||||
assert len(ui_notifications_of_user) == 3
|
||||
assert ui_notifications_of_user[0].name == "logging"
|
||||
assert ui_notifications_of_user[1].name == "print"
|
||||
assert ui_notifications_of_user[2].name == "flash"
|
||||
|
||||
notification = notification_manager.create_notification(
|
||||
"testing title",
|
||||
user.username,
|
||||
target_uis="default",
|
||||
date=datetime.datetime(2022, 7, 31, 20, 23, 49, 541516),
|
||||
body="testing body",
|
||||
data={"key": 1},
|
||||
image="someurl",
|
||||
icon="someurl",
|
||||
timeout=3000,
|
||||
)
|
||||
# the notification was stored in notification_manager.notifications
|
||||
assert len(notification_manager.notifications) == 1
|
||||
assert notification_manager.notifications[0].id == notification.id
|
||||
|
||||
# check if the Notification was created correctly
|
||||
notification_str = """{'title': 'testing title', 'user_id': 'someuser', 'date': datetime.datetime(2022, 7, 31, 20, 23, 49, 541516), 'last_shown_date': {}, 'was_closed_in_target_uis': set(), 'target_uis': {'logging'}, 'notification_type': 'information', 'body': 'testing body', 'data': {'key': 1}, 'image': 'someurl', 'icon': 'someurl', 'timeout': 3000, 'id': 'c8b752e0cb679ee13b44497c4b02b11cfafdd378757666b1f9ee805d4a5e7c5a'}"""
|
||||
assert str(notification) == notification_str
|
||||
|
||||
# capture if the notification was actually shown
|
||||
with caplog.at_level(logging.INFO):
|
||||
notification_manager.show(notification)
|
||||
|
||||
# check if any of the INFO messages was the notification
|
||||
notification_found = False
|
||||
logger.debug(caplog.records[-1])
|
||||
for record in caplog.records:
|
||||
if record.message == notification.to_text() and record.levelname == "INFO":
|
||||
notification_found = True
|
||||
assert notification_found
|
||||
|
||||
# the notification was deleted again
|
||||
assert len(notification_manager.notifications) == 0
|
||||
|
||||
|
||||
def mock_flash(*args, **kwargs):
|
||||
flash_message = str((args, kwargs))
|
||||
print(flash_message)
|
||||
logger.warning(flash_message)
|
||||
|
||||
|
||||
# check that register_user_ui_notifications registers the flash message
|
||||
# check that flash messages would send the correct message to flash
|
||||
@patch("cryptoadvance.specterext.notifications.ui_notifications.flash", mock_flash)
|
||||
def test_sending_flash_notification(specter_with_user: Specter, caplog):
|
||||
|
||||
notification_manager = NotificationManager(
|
||||
host="localhost",
|
||||
port="1234",
|
||||
ssl_cert=None,
|
||||
ssl_key=None,
|
||||
enable_websockets=False,
|
||||
)
|
||||
|
||||
# do not take the admin user but the "someuser", just to make sure "someuser" can use the default_ui_notifications
|
||||
user = specter_with_user.user_manager.get_user("someuser")
|
||||
notification_manager.register_user_ui_notifications(user.username)
|
||||
|
||||
ui_notifications_of_user = notification_manager._get_ui_notifications_of_user(
|
||||
user.username
|
||||
)
|
||||
|
||||
# _register_default_ui_notifications should have created 3 ui_notifications accessible for all users
|
||||
assert len(ui_notifications_of_user) == 3
|
||||
assert ui_notifications_of_user[0].name == "logging"
|
||||
assert ui_notifications_of_user[1].name == "print"
|
||||
assert ui_notifications_of_user[2].name == "flash"
|
||||
|
||||
notification = notification_manager.create_notification(
|
||||
"testing title",
|
||||
user.username,
|
||||
target_uis="flash",
|
||||
date=datetime.datetime(2022, 7, 31, 20, 23, 49, 541516),
|
||||
body="testing body",
|
||||
data={"key": 1},
|
||||
image="someurl",
|
||||
icon="someurl",
|
||||
timeout=3000,
|
||||
)
|
||||
# the notification was stored in notification_manager.notifications
|
||||
assert len(notification_manager.notifications) == 1
|
||||
assert notification_manager.notifications[0].id == notification.id
|
||||
|
||||
# check if the Notification was created correctly
|
||||
notification_str = """{'title': 'testing title', 'user_id': 'someuser', 'date': datetime.datetime(2022, 7, 31, 20, 23, 49, 541516), 'last_shown_date': {}, 'was_closed_in_target_uis': set(), 'target_uis': {'flash'}, 'notification_type': 'information', 'body': 'testing body', 'data': {'key': 1}, 'image': 'someurl', 'icon': 'someurl', 'timeout': 3000, 'id': '56daf971f214d75949a8888654f398d8b8efe14ab027afa1b3393835d4d3edd8'}"""
|
||||
assert str(notification) == notification_str
|
||||
|
||||
# capture if the notification was actually shown. Set it it warning, to only cature the mock_flash message
|
||||
with caplog.at_level(logging.WARNING):
|
||||
notification_manager.show(notification)
|
||||
|
||||
# check if any of the INFO messages was the notification
|
||||
assert (
|
||||
caplog.records[-1].message
|
||||
== """(("testing title\\ntesting body\\nData: {\'key\': 1}", \'information\'), {})"""
|
||||
)
|
||||
assert caplog.records[-1].levelname == "WARNING"
|
||||
|
||||
# the notification was deleted again
|
||||
assert len(notification_manager.notifications) == 0
|
||||
|
|
@ -90,7 +90,7 @@ def test_get_subclasses_for_class(caplog):
|
|||
classlist = get_subclasses_for_clazz(SpecterMigration)
|
||||
assert len(classlist) >= 3
|
||||
classlist = get_subclasses_for_clazz(Service)
|
||||
assert len(classlist) >= 4
|
||||
assert len(classlist) >= 5
|
||||
# checking naively for certain Services would be counterproductive if you import
|
||||
# the class. THis needs to work without importing the class!
|
||||
# But we can test like this:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue