Feature: Notification system (#1766) (#2103)

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:
k9ert 2023-02-07 19:00:44 +01:00 committed by GitHub
parent c087fbcf71
commit 99f5404705
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 2868 additions and 61 deletions

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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",

View file

@ -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.

View file

@ -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

View file

@ -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)

View file

@ -182,6 +182,7 @@ def register():
plaintext_password=password,
config=config,
)
app.specter.service_manager.add_required_services_to_users([user])
flash(
_(

View file

@ -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"

View file

@ -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):

View file

@ -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)

View file

@ -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;*/
}

View file

@ -76,4 +76,6 @@
</script>

View file

@ -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>

View file

@ -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 %}

View file

@ -19,7 +19,8 @@
</ul>
<p>
Same for some macros.
Exploring/testing the <a href="html/notification-system.jinja">Notification System</a>
</p>
<ul>

View 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

View 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),
}
)

View file

@ -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

View 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

View 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")

View file

@ -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

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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>

View file

@ -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 %}

View file

@ -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
});
*/

View file

@ -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
}

View file

@ -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 %}

View 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

View file

@ -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})

View file

@ -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",

View file

@ -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):

View file

@ -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
View 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

View file

@ -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: