From 9729cf0e612e94b07a0f37c86e59a01ef6266ca9 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 31 Jan 2024 15:24:07 +0100 Subject: [PATCH 01/31] WIP --- services/payments/requirements/_base.in | 1 + services/payments/requirements/_base.txt | 50 ++++++++++++++- .../simcore_service_payments/core/settings.py | 6 ++ .../services/notifier_email.py | 61 +++++++++++++++++++ .../unit/test_services_notifier_email.py | 3 + 5 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 services/payments/src/simcore_service_payments/services/notifier_email.py create mode 100644 services/payments/tests/unit/test_services_notifier_email.py diff --git a/services/payments/requirements/_base.in b/services/payments/requirements/_base.in index 5ef19fb42d5..f26e75bf693 100644 --- a/services/payments/requirements/_base.in +++ b/services/payments/requirements/_base.in @@ -16,6 +16,7 @@ cryptography fastapi +fastapi-mail # NOTE: this is an old version because of pydantic2 limitation! httpx packaging python-jose diff --git a/services/payments/requirements/_base.txt b/services/payments/requirements/_base.txt index 85675b4d4e4..9d223e765a6 100644 --- a/services/payments/requirements/_base.txt +++ b/services/payments/requirements/_base.txt @@ -33,10 +33,14 @@ aiohttp==3.8.6 # -c requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../requirements/constraints.txt # aiodocker +aioredis==2.0.1 + # via fastapi-mail aiormq==6.7.7 # via aio-pika aiosignal==1.3.1 # via aiohttp +aiosmtplib==3.0.1 + # via fastapi-mail alembic==1.12.1 # via -r requirements/../../../packages/postgres-database/requirements/_base.in anyio==4.0.0 @@ -54,6 +58,7 @@ arrow==1.3.0 async-timeout==4.0.3 # via # aiohttp + # aioredis # redis asyncpg==0.28.0 # via sqlalchemy @@ -64,6 +69,8 @@ attrs==23.1.0 # referencing bidict==0.22.1 # via python-socketio +blinker==1.7.0 + # via fastapi-mail certifi==2023.7.22 # via # -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt @@ -106,9 +113,13 @@ dnspython==2.4.2 ecdsa==0.18.0 # via python-jose email-validator==2.1.0.post1 - # via pydantic + # via + # fastapi-mail + # pydantic exceptiongroup==1.1.3 # via anyio +fakeredis==2.21.0 + # via fastapi-mail fastapi==0.99.1 # via # -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt @@ -123,6 +134,10 @@ fastapi==0.99.1 # -c requirements/../../../requirements/constraints.txt # -r requirements/../../../packages/service-library/requirements/_fastapi.in # -r requirements/_base.in + # fastapi-mail + # prometheus-fastapi-instrumentator +fastapi-mail==0.4.1 + # via -r requirements/_base.in frozenlist==1.4.0 # via # aiohttp @@ -152,12 +167,26 @@ httpx==0.25.0 # -c requirements/../../../requirements/constraints.txt # -r requirements/../../../packages/service-library/requirements/_fastapi.in # -r requirements/_base.in + # fastapi-mail idna==3.4 # via # anyio # email-validator # httpx # yarl +jinja2==3.1.3 + # via + # -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt + # -c requirements/../../../packages/postgres-database/requirements/../../../requirements/constraints.txt + # -c requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt + # -c requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt + # -c requirements/../../../packages/service-library/requirements/../../../requirements/constraints.txt + # -c requirements/../../../packages/service-library/requirements/./../../../packages/models-library/requirements/../../../requirements/constraints.txt + # -c requirements/../../../packages/service-library/requirements/./../../../packages/settings-library/requirements/../../../requirements/constraints.txt + # -c requirements/../../../packages/service-library/requirements/./../../../requirements/constraints.txt + # -c requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt + # -c requirements/../../../requirements/constraints.txt + # fastapi-mail jsonschema==4.19.2 # via # -r requirements/../../../packages/models-library/requirements/_base.in @@ -181,7 +210,9 @@ mako==1.2.4 markdown-it-py==3.0.0 # via rich markupsafe==2.1.3 - # via mako + # via + # jinja2 + # mako mdurl==0.1.2 # via markdown-it-py multidict==6.0.4 @@ -197,6 +228,12 @@ packaging==23.2 # via -r requirements/_base.in pamqp==3.2.1 # via aiormq +prometheus-client==0.19.0 + # via + # -r requirements/../../../packages/service-library/requirements/_fastapi.in + # prometheus-fastapi-instrumentator +prometheus-fastapi-instrumentator==6.1.0 + # via -r requirements/../../../packages/service-library/requirements/_fastapi.in psycopg2-binary==2.9.9 # via sqlalchemy pyasn1==0.5.0 @@ -226,6 +263,7 @@ pydantic==1.10.13 # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/settings-library/requirements/_base.in # fastapi + # fastapi-mail pygments==2.16.1 # via rich pyinstrument==4.6.0 @@ -241,7 +279,9 @@ python-engineio==4.8.0 python-jose==3.3.0 # via -r requirements/_base.in python-multipart==0.0.6 - # via -r requirements/_base.in + # via + # -r requirements/_base.in + # fastapi-mail python-socketio==5.10.0 # via -r requirements/_base.in pyyaml==6.0.1 @@ -273,6 +313,7 @@ redis==5.0.1 # -c requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../requirements/constraints.txt # -r requirements/../../../packages/service-library/requirements/_base.in + # fakeredis referencing==0.29.3 # via # -c requirements/../../../packages/service-library/requirements/././constraints.txt @@ -315,6 +356,8 @@ sniffio==1.3.0 # anyio # httpcore # httpx +sortedcontainers==2.4.0 + # via fakeredis sqlalchemy==1.4.50 # via # -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt @@ -366,6 +409,7 @@ typing-extensions==4.8.0 # via # aiodebug # aiodocker + # aioredis # alembic # fastapi # pydantic diff --git a/services/payments/src/simcore_service_payments/core/settings.py b/services/payments/src/simcore_service_payments/core/settings.py index d8b89accb2e..c6631c0e7d7 100644 --- a/services/payments/src/simcore_service_payments/core/settings.py +++ b/services/payments/src/simcore_service_payments/core/settings.py @@ -5,6 +5,7 @@ from pydantic import Field, HttpUrl, PositiveFloat, SecretStr, parse_obj_as, validator from settings_library.application import BaseApplicationSettings from settings_library.basic_types import LogLevel, VersionTag +from settings_library.email import SMTPSettings from settings_library.postgres import PostgresSettings from settings_library.rabbit import RabbitSettings from settings_library.resource_usage_tracker import ResourceUsageTrackerSettings @@ -114,3 +115,8 @@ class ApplicationSettings(_BaseApplicationSettings): ) PAYMENTS_PROMETHEUS_INSTRUMENTATION_ENABLED: bool = True + + PAYMENTS_EMAIL: SMTPSettings | None = Field( + auto_default_from_env=True, + description="optional email (see notifier_email service)", + ) diff --git a/services/payments/src/simcore_service_payments/services/notifier_email.py b/services/payments/src/simcore_service_payments/services/notifier_email.py new file mode 100644 index 00000000000..969a0dafcb3 --- /dev/null +++ b/services/payments/src/simcore_service_payments/services/notifier_email.py @@ -0,0 +1,61 @@ +import contextlib + +from fastapi import FastAPI +from fastapi_mail import ConnectionConfig, FastMail +from pydantic import EmailStr +from servicelib.fastapi.app_state import SingletonInAppStateMixin +from settings_library.email import EmailProtocol, SMTPSettings + +from ..core.settings import ApplicationSettings + + +class EmailNotifier(SingletonInAppStateMixin): + app_state_name: str = "email_notifier" + + def __init__( + self, settings: SMTPSettings, support_email: EmailStr, support_display_name: str + ): + + assert settings.SMTP_USERNAME + assert settings.SMTP_PASSWORD + + conf = ConnectionConfig( + MAIL_USERNAME=settings.SMTP_USERNAME, + MAIL_PASSWORD=settings.SMTP_PASSWORD.get_secret_value(), + MAIL_PORT=settings.SMTP_PORT, + MAIL_SERVER=settings.SMTP_HOST, + MAIL_TLS=settings.SMTP_PROTOCOL == EmailProtocol.TLS, + MAIL_SSL=settings.SMTP_PROTOCOL == EmailProtocol.STARTTLS, + MAIL_DEBUG=0, + MAIL_FROM=support_email, + MAIL_FROM_NAME=support_display_name, + TEMPLATE_FOLDER=None, + SUPPRESS_SEND=0, + USE_CREDENTIALS=True, + VALIDATE_CERTS=True, + ) + self._fm = FastMail(conf) + + async def send_message(self, message): + await self._fm.send_message(message) + + +def setup_notifier_email(app: FastAPI): + app_settings: ApplicationSettings = app.state.settings + + if settings := app_settings.PAYMENTS_EMAIL: + + async def _on_startup() -> None: + assert app.state.external_socketio # nosec + + notifier = EmailNotifier(settings) + notifier.set_to_app_state(app) + + assert EmailNotifier.get_from_app_state(app) == notifier # nosec + + async def _on_shutdown() -> None: + with contextlib.suppress(AttributeError): + EmailNotifier.pop_from_app_state(app) + + app.add_event_handler("startup", _on_startup) + app.add_event_handler("shutdown", _on_shutdown) diff --git a/services/payments/tests/unit/test_services_notifier_email.py b/services/payments/tests/unit/test_services_notifier_email.py new file mode 100644 index 00000000000..0ecf5c9f945 --- /dev/null +++ b/services/payments/tests/unit/test_services_notifier_email.py @@ -0,0 +1,3 @@ +def test_it(): + ... + # how we have been doing it From f3b08f54b25a22d9fa782ee438125cf0b293de66 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 1 Feb 2024 17:31:54 +0100 Subject: [PATCH 02/31] WIP --- .../src/settings_library/email.py | 4 + .../services/notifier_email.py | 2 + .../unit/test_services_notifier_email.py | 80 ++++++++++++++++++- .../simcore_service_webserver/email/_core.py | 15 ++-- 4 files changed, 89 insertions(+), 12 deletions(-) diff --git a/packages/settings-library/src/settings_library/email.py b/packages/settings-library/src/settings_library/email.py index 6f0838e93fa..bd5ed0ab261 100644 --- a/packages/settings-library/src/settings_library/email.py +++ b/packages/settings-library/src/settings_library/email.py @@ -58,3 +58,7 @@ def enabled_tls_required_authentication(cls, values): msg = "when using SMTP_PROTOCOL other than UNENCRYPTED username and password are required" raise ValueError(msg) return values + + @property + def has_credentials(self) -> bool: + return self.SMTP_USERNAME is not None and self.SMTP_PASSWORD is not None diff --git a/services/payments/src/simcore_service_payments/services/notifier_email.py b/services/payments/src/simcore_service_payments/services/notifier_email.py index 969a0dafcb3..c5116ce7634 100644 --- a/services/payments/src/simcore_service_payments/services/notifier_email.py +++ b/services/payments/src/simcore_service_payments/services/notifier_email.py @@ -55,6 +55,8 @@ async def _on_startup() -> None: async def _on_shutdown() -> None: with contextlib.suppress(AttributeError): + # FIXME: is there a reason really to pop it from state? Only to make sure + # nobody is using it afterwards EmailNotifier.pop_from_app_state(app) app.add_event_handler("startup", _on_startup) diff --git a/services/payments/tests/unit/test_services_notifier_email.py b/services/payments/tests/unit/test_services_notifier_email.py index 0ecf5c9f945..bf97ed3d423 100644 --- a/services/payments/tests/unit/test_services_notifier_email.py +++ b/services/payments/tests/unit/test_services_notifier_email.py @@ -1,3 +1,77 @@ -def test_it(): - ... - # how we have been doing it +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + +from email.message import EmailMessage +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from pathlib import Path + +import aiosmtplib +import pytest +from pytest_simcore.helpers.typing_env import EnvVarsDict +from pytest_simcore.helpers.utils_envs import setenvs_from_envfile +from settings_library.email import EmailProtocol, SMTPSettings + + +@pytest.fixture +def tmp_environment( + osparc_simcore_root_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> EnvVarsDict: + return setenvs_from_envfile(monkeypatch, osparc_simcore_root_dir / ".secrets") + + +async def test_it(tmp_environment: EnvVarsDict): + + settings = SMTPSettings.create_from_envs() + + async with aiosmtplib.SMTP( + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + # FROM https://aiosmtplib.readthedocs.io/en/stable/usage.html#starttls-connections + # By default, if the server advertises STARTTLS support, aiosmtplib will upgrade the connection automatically. + # Setting use_tls=True for STARTTLS servers will typically result in a connection error + # To opt out of STARTTLS on connect, pass start_tls=False. + # NOTE: for that reason TLS and STARTLS are mutally exclusive + use_tls=settings.SMTP_PROTOCOL == EmailProtocol.TLS, + start_tls=settings.SMTP_PROTOCOL == EmailProtocol.STARTTLS, + ) as smtp: + if settings.has_credentials: + assert settings.SMTP_USERNAME + assert settings.SMTP_PASSWORD + await smtp.login( + settings.SMTP_USERNAME, + settings.SMTP_PASSWORD.get_secret_value(), + ) + + # Session ready to send email + + def compose_simple(): + # compose simple + msg = EmailMessage() + msg["From"] = "support@osparc.io" + msg["To"] = "crespo@speag.com" + msg["Subject"] = "Hello World!" + msg.set_content("Sent via aiosmtplib") + return msg + + def compose_multipart(): + # multipart + msg = MIMEMultipart("alternative") + msg["From"] = "support@osparc.io" + msg["To"] = "crespo@speag.com" + msg["Subject"] = "Hello World!" + + plain_text_message = MIMEText("Sent via aiosmtplib", "plain", "utf-8") + html_message = MIMEText( + "
Hi there!
+This is your + + invoice + . +
+ """ + msg = compose_email(msg, text_body, html_body) + + await smtp.send_message(msg) + + # render a template + # common CSS+HTML From bfe45500ca453c3f0d11e26481a5d3b76e1e410e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 2 Feb 2024 11:50:40 +0100 Subject: [PATCH 04/31] test notifier --- .../unit/test_services_notifier_email.py | 147 ++++++++++-------- 1 file changed, 78 insertions(+), 69 deletions(-) diff --git a/services/payments/tests/unit/test_services_notifier_email.py b/services/payments/tests/unit/test_services_notifier_email.py index ffb6a350220..9226718daa4 100644 --- a/services/payments/tests/unit/test_services_notifier_email.py +++ b/services/payments/tests/unit/test_services_notifier_email.py @@ -4,6 +4,7 @@ # pylint: disable=too-many-arguments import mimetypes +from contextlib import asynccontextmanager from email.headerregistry import Address from email.message import EmailMessage from email.utils import make_msgid @@ -33,10 +34,8 @@ def guess_file_type(file_path: Path) -> tuple[str, str]: return maintype, subtype -async def test_it(tmp_environment: EnvVarsDict, osparc_simcore_root_dir: Path): - - settings = SMTPSettings.create_from_envs() - +@asynccontextmanager +async def email_session(settings: SMTPSettings): async with aiosmtplib.SMTP( hostname=settings.SMTP_HOST, port=settings.SMTP_PORT, @@ -56,81 +55,91 @@ async def test_it(tmp_environment: EnvVarsDict, osparc_simcore_root_dir: Path): settings.SMTP_PASSWORD.get_secret_value(), ) - def compose_email(msg: EmailMessage, text_body, html_body) -> EmailMessage: - # Text version - msg.set_content( - f"""\ - {text_body} + yield smtp - Done with love at Z43 - """ - ) - - # HTML version - logo_cid = make_msgid() - msg.add_alternative( - f"""\ - - - - {html_body} - Done with love at - - - """, - subtype="html", - ) - assert msg.is_multipart() +async def test_it(tmp_environment: EnvVarsDict, osparc_simcore_root_dir: Path): - logo_path = ( - osparc_simcore_root_dir - / "services/static-webserver/client/source/resource/osparc/z43-logo.png" - ) - maintype, subtype = guess_file_type(logo_path) - msg.get_payload(1).add_related( - logo_path.read_bytes(), - maintype=maintype, - subtype=subtype, - cid=logo_cid, - ) + settings = SMTPSettings.create_from_envs() - # Attach file - pdf_path = osparc_simcore_root_dir / "ignore.pdf" - maintype, subtype = guess_file_type(pdf_path) - msg.add_attachment( - pdf_path.read_bytes(), - filename=pdf_path.name, - maintype=maintype, - subtype=subtype, - ) - return msg + def compose_branded_email( + msg: EmailMessage, text_body, html_body, attachments: list[Path] + ) -> EmailMessage: + # Text version + msg.set_content( + f"""\ + {text_body} - msg = EmailMessage() - msg["From"] = Address( - display_name="osparc support", addr_spec="support@osparc.io" - ) - msg["To"] = Address( - display_name="Pedro Crespo-Valero", addr_spec="crespo@speag.com" + Done with love at Z43 + """ ) - msg["Subject"] = "Payment invoice" - text_body = """\ - Hi there, + # HTML version + logo_cid = make_msgid() + msg.add_alternative( + f"""\ + + + + {html_body} + Done with love at + + + """, + subtype="html", + ) - This is your invoice. - """ + assert msg.is_multipart() - html_body = """\ -Hi there!
-This is your - - invoice - . -
- """ - msg = compose_email(msg, text_body, html_body) + logo_path = ( + osparc_simcore_root_dir + / "services/static-webserver/client/source/resource/osparc/z43-logo.png" + ) + maintype, subtype = guess_file_type(logo_path) + msg.get_payload(1).add_related( + logo_path.read_bytes(), + maintype=maintype, + subtype=subtype, + cid=logo_cid, + ) + # Attach files + for attachment_path in attachments: + maintype, subtype = guess_file_type(attachment_path) + msg.add_attachment( + attachment_path.read_bytes(), + filename=attachment_path.name, + maintype=maintype, + subtype=subtype, + ) + return msg + + msg = EmailMessage() + msg["From"] = Address(display_name="osparc support", addr_spec="support@osparc.io") + msg["To"] = Address( + display_name="Pedro Crespo-Valero", addr_spec="crespo@speag.com" + ) + msg["Subject"] = "Payment invoice" + + text_body = """\ + Hi there, + + This is your invoice. + """ + + html_body = """\ +Hi there!
+This is your + + invoice + . +
+ """ + msg = compose_branded_email( + msg, text_body, html_body, attachments=[osparc_simcore_root_dir / "ignore.pdf"] + ) + + async with email_session(settings) as smtp: await smtp.send_message(msg) # render a template From f2b8903098ac56eacf63616b3163ea5695092bb3 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 6 Feb 2024 15:43:51 +0100 Subject: [PATCH 05/31] notifications --- .../api/rest/_acknowledgements.py | 6 +- .../services/notifier.py | 78 ++++++++++-------- .../services/notifier_abc.py | 28 +++++++ .../services/notifier_email.py | 82 ++++++------------- .../services/notifier_ws.py | 61 ++++++++++++++ .../services/payments.py | 4 +- .../services/payments_methods.py | 4 +- .../tests/unit/test_services_notifier.py | 5 +- 8 files changed, 169 insertions(+), 99 deletions(-) create mode 100644 services/payments/src/simcore_service_payments/services/notifier_abc.py create mode 100644 services/payments/src/simcore_service_payments/services/notifier_ws.py diff --git a/services/payments/src/simcore_service_payments/api/rest/_acknowledgements.py b/services/payments/src/simcore_service_payments/api/rest/_acknowledgements.py index 623e49276e7..32526bbdc4b 100644 --- a/services/payments/src/simcore_service_payments/api/rest/_acknowledgements.py +++ b/services/payments/src/simcore_service_payments/api/rest/_acknowledgements.py @@ -19,7 +19,7 @@ PaymentMethodID, ) from ...services import payments, payments_methods -from ...services.notifier import Notifier +from ...services.notifier import NotifierService from ...services.resource_usage_tracker import ResourceUsageTrackerApi from ._dependencies import ( create_repository, @@ -46,7 +46,7 @@ async def acknowledge_payment( PaymentsMethodsRepo, Depends(create_repository(PaymentsMethodsRepo)) ], rut_api: Annotated[ResourceUsageTrackerApi, Depends(get_rut_api)], - notifier: Annotated[Notifier, Depends(get_from_app_state(Notifier))], + notifier: Annotated[NotifierService, Depends(get_from_app_state(NotifierService))], background_tasks: BackgroundTasks, ): """completes (ie. ack) request initated by `/init` on the payments-gateway API""" @@ -96,7 +96,7 @@ async def acknowledge_payment_method( repo: Annotated[ PaymentsMethodsRepo, Depends(create_repository(PaymentsMethodsRepo)) ], - notifier: Annotated[Notifier, Depends(get_from_app_state(Notifier))], + notifier: Annotated[NotifierService, Depends(get_from_app_state(NotifierService))], background_tasks: BackgroundTasks, ): """completes (ie. ack) request initated by `/payments-methods:init` on the payments-gateway API""" diff --git a/services/payments/src/simcore_service_payments/services/notifier.py b/services/payments/src/simcore_service_payments/services/notifier.py index 96f47d96061..34bfcf545c4 100644 --- a/services/payments/src/simcore_service_payments/services/notifier.py +++ b/services/payments/src/simcore_service_payments/services/notifier.py @@ -1,14 +1,8 @@ +import asyncio import contextlib import logging -import socketio from fastapi import FastAPI -from fastapi.encoders import jsonable_encoder -from models_library.api_schemas_payments.socketio import ( - SOCKET_IO_PAYMENT_COMPLETED_EVENT, - SOCKET_IO_PAYMENT_METHOD_ACKED_EVENT, -) -from models_library.api_schemas_webserver.socketio import SocketIORoomStr from models_library.api_schemas_webserver.wallets import ( PaymentMethodTransaction, PaymentTransaction, @@ -16,20 +10,21 @@ from models_library.users import UserID from servicelib.fastapi.app_state import SingletonInAppStateMixin +from ..core.settings import ApplicationSettings from ..db.payment_users_repo import PaymentsUsersRepo +from .notifier_abc import NotificationProvider +from .notifier_email import EmailProvider +from .notifier_ws import WebSocketProvider from .postgres import get_engine _logger = logging.getLogger(__name__) -class Notifier(SingletonInAppStateMixin): +class NotifierService(SingletonInAppStateMixin): app_state_name: str = "notifier" - def __init__( - self, sio_manager: socketio.AsyncAioPikaManager, users_repo: PaymentsUsersRepo - ): - self._sio_manager = sio_manager - self._users_repo = users_repo + def __init__(self, *providers): + self.providers: list[NotificationProvider] = list(providers) async def notify_payment_completed( self, @@ -37,19 +32,14 @@ async def notify_payment_completed( payment: PaymentTransaction, ): if payment.completed_at is None: - msg = "Incomplete payment" + msg = "Cannot notify unAcked payment" raise ValueError(msg) - user_primary_group_id = await self._users_repo.get_primary_group_id(user_id) - - # NOTE: We assume that the user has been added to all - # rooms associated to his groups - assert payment.completed_at is not None # nosec - - return await self._sio_manager.emit( - SOCKET_IO_PAYMENT_COMPLETED_EVENT, - data=jsonable_encoder(payment, by_alias=True), - room=SocketIORoomStr.from_group_id(user_primary_group_id), + await asyncio.gather( + *( + provider.notify_payment_completed(user_id=user_id, payment=payment) + for provider in self.providers + ) ) async def notify_payment_method_acked( @@ -57,29 +47,47 @@ async def notify_payment_method_acked( user_id: UserID, payment_method: PaymentMethodTransaction, ): - user_primary_group_id = await self._users_repo.get_primary_group_id(user_id) + if payment_method.state == "PENDING": + msg = "Cannot notify unAcked payment-method" + raise ValueError(msg) - return await self._sio_manager.emit( - SOCKET_IO_PAYMENT_METHOD_ACKED_EVENT, - data=jsonable_encoder(payment_method, by_alias=True), - room=SocketIORoomStr.from_group_id(user_primary_group_id), + await asyncio.gather( + *( + provider.notify_payment_method_acked( + user_id=user_id, payment_method=payment_method + ) + for provider in self.providers + ) ) def setup_notifier(app: FastAPI): + app_settings: ApplicationSettings = app.state.settings + async def _on_startup() -> None: assert app.state.external_socketio # nosec - notifier = Notifier( - sio_manager=app.state.external_socketio, - users_repo=PaymentsUsersRepo(get_engine(app)), - ) + providers: list[NotificationProvider] = [ + WebSocketProvider( + sio_manager=app.state.external_socketio, + users_repo=PaymentsUsersRepo(get_engine(app)), + ), + ] + + if email_settings := app_settings.PAYMENTS_EMAIL: + providers.append( + EmailProvider( + email_settings, users_repo=PaymentsUsersRepo(get_engine(app)) + ) + ) + + notifier = NotifierService(*providers) notifier.set_to_app_state(app) - assert Notifier.get_from_app_state(app) == notifier # nosec + assert NotifierService.get_from_app_state(app) == notifier # nosec async def _on_shutdown() -> None: with contextlib.suppress(AttributeError): - Notifier.pop_from_app_state(app) + NotifierService.pop_from_app_state(app) app.add_event_handler("startup", _on_startup) app.add_event_handler("shutdown", _on_shutdown) diff --git a/services/payments/src/simcore_service_payments/services/notifier_abc.py b/services/payments/src/simcore_service_payments/services/notifier_abc.py new file mode 100644 index 00000000000..ad8d76ab2c4 --- /dev/null +++ b/services/payments/src/simcore_service_payments/services/notifier_abc.py @@ -0,0 +1,28 @@ +import logging +from abc import ABC, abstractmethod + +from models_library.api_schemas_webserver.wallets import ( + PaymentMethodTransaction, + PaymentTransaction, +) +from models_library.users import UserID + +_logger = logging.getLogger(__name__) + + +class NotificationProvider(ABC): + @abstractmethod + async def notify_payment_completed( + self, + user_id: UserID, + payment: PaymentTransaction, + ): + ... + + @abstractmethod + async def notify_payment_method_acked( + self, + user_id: UserID, + payment_method: PaymentMethodTransaction, + ): + ... diff --git a/services/payments/src/simcore_service_payments/services/notifier_email.py b/services/payments/src/simcore_service_payments/services/notifier_email.py index c5116ce7634..6960ae360fb 100644 --- a/services/payments/src/simcore_service_payments/services/notifier_email.py +++ b/services/payments/src/simcore_service_payments/services/notifier_email.py @@ -1,63 +1,35 @@ -import contextlib +from models_library.api_schemas_webserver.wallets import ( + PaymentMethodTransaction, + PaymentTransaction, +) +from models_library.users import UserID +from settings_library.email import SMTPSettings -from fastapi import FastAPI -from fastapi_mail import ConnectionConfig, FastMail -from pydantic import EmailStr -from servicelib.fastapi.app_state import SingletonInAppStateMixin -from settings_library.email import EmailProtocol, SMTPSettings +from ..db.payment_users_repo import PaymentsUsersRepo +from .notifier_abc import NotificationProvider -from ..core.settings import ApplicationSettings +class EmailService: + # renders and sends emails + ... -class EmailNotifier(SingletonInAppStateMixin): - app_state_name: str = "email_notifier" - def __init__( - self, settings: SMTPSettings, support_email: EmailStr, support_display_name: str - ): - - assert settings.SMTP_USERNAME - assert settings.SMTP_PASSWORD - - conf = ConnectionConfig( - MAIL_USERNAME=settings.SMTP_USERNAME, - MAIL_PASSWORD=settings.SMTP_PASSWORD.get_secret_value(), - MAIL_PORT=settings.SMTP_PORT, - MAIL_SERVER=settings.SMTP_HOST, - MAIL_TLS=settings.SMTP_PROTOCOL == EmailProtocol.TLS, - MAIL_SSL=settings.SMTP_PROTOCOL == EmailProtocol.STARTTLS, - MAIL_DEBUG=0, - MAIL_FROM=support_email, - MAIL_FROM_NAME=support_display_name, - TEMPLATE_FOLDER=None, - SUPPRESS_SEND=0, - USE_CREDENTIALS=True, - VALIDATE_CERTS=True, - ) - self._fm = FastMail(conf) - - async def send_message(self, message): - await self._fm.send_message(message) - - -def setup_notifier_email(app: FastAPI): - app_settings: ApplicationSettings = app.state.settings +class EmailProvider(NotificationProvider): + # interfaces with the notification system + def __init__(self, settings: SMTPSettings, users_repo: PaymentsUsersRepo): + ... - if settings := app_settings.PAYMENTS_EMAIL: - - async def _on_startup() -> None: - assert app.state.external_socketio # nosec - - notifier = EmailNotifier(settings) - notifier.set_to_app_state(app) - - assert EmailNotifier.get_from_app_state(app) == notifier # nosec + async def notify_payment_completed( + self, + user_id: UserID, + payment: PaymentTransaction, + ): - async def _on_shutdown() -> None: - with contextlib.suppress(AttributeError): - # FIXME: is there a reason really to pop it from state? Only to make sure - # nobody is using it afterwards - EmailNotifier.pop_from_app_state(app) + raise NotImplementedError - app.add_event_handler("startup", _on_startup) - app.add_event_handler("shutdown", _on_shutdown) + async def notify_payment_method_acked( + self, + user_id: UserID, + payment_method: PaymentMethodTransaction, + ): + raise NotImplementedError diff --git a/services/payments/src/simcore_service_payments/services/notifier_ws.py b/services/payments/src/simcore_service_payments/services/notifier_ws.py new file mode 100644 index 00000000000..c4ab12b04a0 --- /dev/null +++ b/services/payments/src/simcore_service_payments/services/notifier_ws.py @@ -0,0 +1,61 @@ +import logging + +import socketio +from fastapi.encoders import jsonable_encoder +from models_library.api_schemas_payments.socketio import ( + SOCKET_IO_PAYMENT_COMPLETED_EVENT, + SOCKET_IO_PAYMENT_METHOD_ACKED_EVENT, +) +from models_library.api_schemas_webserver.socketio import SocketIORoomStr +from models_library.api_schemas_webserver.wallets import ( + PaymentMethodTransaction, + PaymentTransaction, +) +from models_library.users import UserID + +from ..db.payment_users_repo import PaymentsUsersRepo +from .notifier_abc import NotificationProvider + +_logger = logging.getLogger(__name__) + + +class WebSocketProvider(NotificationProvider): + def __init__( + self, sio_manager: socketio.AsyncAioPikaManager, users_repo: PaymentsUsersRepo + ): + self._sio_manager = sio_manager + self._users_repo = users_repo + + async def notify_payment_completed( + self, + user_id: UserID, + payment: PaymentTransaction, + ): + if payment.completed_at is None: + msg = "Incomplete payment" + raise ValueError(msg) + + user_primary_group_id = await self._users_repo.get_primary_group_id(user_id) + + # NOTE: We assume that the user has been added to all + # rooms associated to his groups + assert payment.completed_at is not None # nosec + + return await self._sio_manager.emit( + SOCKET_IO_PAYMENT_COMPLETED_EVENT, + data=jsonable_encoder(payment, by_alias=True), + room=SocketIORoomStr.from_group_id(user_primary_group_id), + ) + + async def notify_payment_method_acked( + self, + user_id: UserID, + payment_method: PaymentMethodTransaction, + ): + user_primary_group_id = await self._users_repo.get_primary_group_id(user_id) + + return await self._sio_manager.emit( + SOCKET_IO_PAYMENT_METHOD_ACKED_EVENT, + data=jsonable_encoder(payment_method, by_alias=True), + room=SocketIORoomStr.from_group_id(user_primary_group_id), + ) diff --git a/services/payments/src/simcore_service_payments/services/payments.py b/services/payments/src/simcore_service_payments/services/payments.py index bcd6083bde3..418604418b1 100644 --- a/services/payments/src/simcore_service_payments/services/payments.py +++ b/services/payments/src/simcore_service_payments/services/payments.py @@ -40,7 +40,7 @@ from ..models.payments_gateway import InitPayment, PaymentInitiated from ..models.schemas.acknowledgements import AckPayment, AckPaymentWithPaymentMethod from ..services.resource_usage_tracker import ResourceUsageTrackerApi -from .notifier import Notifier +from .notifier import NotifierService from .payments_gateway import PaymentsGatewayApi _logger = logging.getLogger() @@ -148,7 +148,7 @@ async def acknowledge_one_time_payment( async def on_payment_completed( transaction: PaymentsTransactionsDB, rut_api: ResourceUsageTrackerApi, - notifier: Notifier | None, + notifier: NotifierService | None, ): assert transaction.completed_at is not None # nosec assert transaction.initiated_at < transaction.completed_at # nosec diff --git a/services/payments/src/simcore_service_payments/services/payments_methods.py b/services/payments/src/simcore_service_payments/services/payments_methods.py index 63a117e58ef..75775dc911d 100644 --- a/services/payments/src/simcore_service_payments/services/payments_methods.py +++ b/services/payments/src/simcore_service_payments/services/payments_methods.py @@ -35,7 +35,7 @@ from ..models.payments_gateway import GetPaymentMethod, InitPaymentMethod from ..models.schemas.acknowledgements import AckPaymentMethod from ..models.utils import merge_models -from .notifier import Notifier +from .notifier import NotifierService from .payments_gateway import PaymentsGatewayApi _logger = logging.getLogger(__name__) @@ -121,7 +121,7 @@ async def acknowledge_creation_of_payment_method( async def on_payment_method_completed( - payment_method: PaymentsMethodsDB, notifier: Notifier + payment_method: PaymentsMethodsDB, notifier: NotifierService ): assert payment_method.completed_at is not None # nosec assert payment_method.initiated_at < payment_method.completed_at # nosec diff --git a/services/payments/tests/unit/test_services_notifier.py b/services/payments/tests/unit/test_services_notifier.py index fcf5f776dd5..3cdeb7224c2 100644 --- a/services/payments/tests/unit/test_services_notifier.py +++ b/services/payments/tests/unit/test_services_notifier.py @@ -28,7 +28,7 @@ from settings_library.rabbit import RabbitSettings from simcore_service_payments.models.db import PaymentsTransactionsDB from simcore_service_payments.models.db_to_api import to_payments_api_model -from simcore_service_payments.services.notifier import Notifier +from simcore_service_payments.services.notifier import NotifierService from simcore_service_payments.services.rabbitmq import get_rabbitmq_settings from socketio import AsyncServer from tenacity import AsyncRetrying @@ -59,6 +59,7 @@ def app_environment( ): # set environs monkeypatch.delenv("PAYMENTS_RABBITMQ", raising=False) + monkeypatch.delenv("PAYMENTS_EMAIL", raising=False) return setenvs_from_dict( monkeypatch, @@ -118,7 +119,7 @@ async def _(): user_id=user_id, completed_at=arrow.utcnow().datetime ) ) - notifier: Notifier = Notifier.get_from_app_state(app) + notifier: NotifierService = NotifierService.get_from_app_state(app) await notifier.notify_payment_completed( user_id=transaction.user_id, payment=to_payments_api_model(transaction) ) From 1d0f36ba70272f3ecbb7992f8c0f5645385e476a Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 6 Feb 2024 15:44:37 +0100 Subject: [PATCH 06/31] WIP prototyping emails --- .../unit/test_services_notifier_email.py | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/services/payments/tests/unit/test_services_notifier_email.py b/services/payments/tests/unit/test_services_notifier_email.py index 9226718daa4..27c4e5d5e1c 100644 --- a/services/payments/tests/unit/test_services_notifier_email.py +++ b/services/payments/tests/unit/test_services_notifier_email.py @@ -3,6 +3,7 @@ # pylint: disable=unused-variable # pylint: disable=too-many-arguments +import base64 import mimetypes from contextlib import asynccontextmanager from email.headerregistry import Address @@ -62,6 +63,27 @@ async def test_it(tmp_environment: EnvVarsDict, osparc_simcore_root_dir: Path): settings = SMTPSettings.create_from_envs() + base_template_html = """\ + + + ++ {% for movie in data['movies'] %} {%if movie['title']!="Terminator" %} + {{ movie['title'] }} + {% endif %} {% endfor %} +
+ {% endblock %} + """ + def compose_branded_email( msg: EmailMessage, text_body, html_body, attachments: list[Path] ) -> EmailMessage: @@ -75,14 +97,27 @@ def compose_branded_email( ) # HTML version + logo_path = ( + osparc_simcore_root_dir + / "services/static-webserver/client/source/resource/osparc/z43-logo.png" + ) + + encoded = base64.b64encode(logo_path.read_bytes()).decode() + img_src_as_base64 = f'"data:image/jpg;base64,{encoded}">' + assert img_src_as_base64 + + # Adding an image as CID attachments (which get embedded with a MIME object) logo_cid = make_msgid() + img_src_as_cid_atttachment = f'"cid:{logo_cid[1:-1]}"' + + img_src = img_src_as_cid_atttachment msg.add_alternative( f"""\ {html_body} - Done with love at + Done with love at """, @@ -91,10 +126,6 @@ def compose_branded_email( assert msg.is_multipart() - logo_path = ( - osparc_simcore_root_dir - / "services/static-webserver/client/source/resource/osparc/z43-logo.png" - ) maintype, subtype = guess_file_type(logo_path) msg.get_payload(1).add_related( logo_path.read_bytes(), @@ -114,6 +145,7 @@ def compose_branded_email( ) return msg + # this is the new way to cmpose emails msg = EmailMessage() msg["From"] = Address(display_name="osparc support", addr_spec="support@osparc.io") msg["To"] = Address( @@ -144,3 +176,5 @@ def compose_branded_email( # render a template # common CSS+HTML + + # compose simple email for From b30363a579acbad5f7bde0546eee9b7717558b4a Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 6 Feb 2024 15:45:23 +0100 Subject: [PATCH 07/31] minor --- .../payments/src/simcore_service_payments/services/notifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/payments/src/simcore_service_payments/services/notifier.py b/services/payments/src/simcore_service_payments/services/notifier.py index 34bfcf545c4..d1738911b27 100644 --- a/services/payments/src/simcore_service_payments/services/notifier.py +++ b/services/payments/src/simcore_service_payments/services/notifier.py @@ -32,7 +32,7 @@ async def notify_payment_completed( payment: PaymentTransaction, ): if payment.completed_at is None: - msg = "Cannot notify unAcked payment" + msg = "Cannot notify incomplete payment" raise ValueError(msg) await asyncio.gather( From b208ccf38c76503ba09b01d42d128cd39bf6e356 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 6 Feb 2024 15:46:47 +0100 Subject: [PATCH 08/31] reverts fastapi-mail --- services/payments/requirements/_base.in | 1 - services/payments/requirements/_base.txt | 50 ++---------------------- 2 files changed, 3 insertions(+), 48 deletions(-) diff --git a/services/payments/requirements/_base.in b/services/payments/requirements/_base.in index f26e75bf693..5ef19fb42d5 100644 --- a/services/payments/requirements/_base.in +++ b/services/payments/requirements/_base.in @@ -16,7 +16,6 @@ cryptography fastapi -fastapi-mail # NOTE: this is an old version because of pydantic2 limitation! httpx packaging python-jose diff --git a/services/payments/requirements/_base.txt b/services/payments/requirements/_base.txt index 9d223e765a6..85675b4d4e4 100644 --- a/services/payments/requirements/_base.txt +++ b/services/payments/requirements/_base.txt @@ -33,14 +33,10 @@ aiohttp==3.8.6 # -c requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../requirements/constraints.txt # aiodocker -aioredis==2.0.1 - # via fastapi-mail aiormq==6.7.7 # via aio-pika aiosignal==1.3.1 # via aiohttp -aiosmtplib==3.0.1 - # via fastapi-mail alembic==1.12.1 # via -r requirements/../../../packages/postgres-database/requirements/_base.in anyio==4.0.0 @@ -58,7 +54,6 @@ arrow==1.3.0 async-timeout==4.0.3 # via # aiohttp - # aioredis # redis asyncpg==0.28.0 # via sqlalchemy @@ -69,8 +64,6 @@ attrs==23.1.0 # referencing bidict==0.22.1 # via python-socketio -blinker==1.7.0 - # via fastapi-mail certifi==2023.7.22 # via # -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt @@ -113,13 +106,9 @@ dnspython==2.4.2 ecdsa==0.18.0 # via python-jose email-validator==2.1.0.post1 - # via - # fastapi-mail - # pydantic + # via pydantic exceptiongroup==1.1.3 # via anyio -fakeredis==2.21.0 - # via fastapi-mail fastapi==0.99.1 # via # -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt @@ -134,10 +123,6 @@ fastapi==0.99.1 # -c requirements/../../../requirements/constraints.txt # -r requirements/../../../packages/service-library/requirements/_fastapi.in # -r requirements/_base.in - # fastapi-mail - # prometheus-fastapi-instrumentator -fastapi-mail==0.4.1 - # via -r requirements/_base.in frozenlist==1.4.0 # via # aiohttp @@ -167,26 +152,12 @@ httpx==0.25.0 # -c requirements/../../../requirements/constraints.txt # -r requirements/../../../packages/service-library/requirements/_fastapi.in # -r requirements/_base.in - # fastapi-mail idna==3.4 # via # anyio # email-validator # httpx # yarl -jinja2==3.1.3 - # via - # -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt - # -c requirements/../../../packages/postgres-database/requirements/../../../requirements/constraints.txt - # -c requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt - # -c requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt - # -c requirements/../../../packages/service-library/requirements/../../../requirements/constraints.txt - # -c requirements/../../../packages/service-library/requirements/./../../../packages/models-library/requirements/../../../requirements/constraints.txt - # -c requirements/../../../packages/service-library/requirements/./../../../packages/settings-library/requirements/../../../requirements/constraints.txt - # -c requirements/../../../packages/service-library/requirements/./../../../requirements/constraints.txt - # -c requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt - # -c requirements/../../../requirements/constraints.txt - # fastapi-mail jsonschema==4.19.2 # via # -r requirements/../../../packages/models-library/requirements/_base.in @@ -210,9 +181,7 @@ mako==1.2.4 markdown-it-py==3.0.0 # via rich markupsafe==2.1.3 - # via - # jinja2 - # mako + # via mako mdurl==0.1.2 # via markdown-it-py multidict==6.0.4 @@ -228,12 +197,6 @@ packaging==23.2 # via -r requirements/_base.in pamqp==3.2.1 # via aiormq -prometheus-client==0.19.0 - # via - # -r requirements/../../../packages/service-library/requirements/_fastapi.in - # prometheus-fastapi-instrumentator -prometheus-fastapi-instrumentator==6.1.0 - # via -r requirements/../../../packages/service-library/requirements/_fastapi.in psycopg2-binary==2.9.9 # via sqlalchemy pyasn1==0.5.0 @@ -263,7 +226,6 @@ pydantic==1.10.13 # -r requirements/../../../packages/service-library/requirements/_base.in # -r requirements/../../../packages/settings-library/requirements/_base.in # fastapi - # fastapi-mail pygments==2.16.1 # via rich pyinstrument==4.6.0 @@ -279,9 +241,7 @@ python-engineio==4.8.0 python-jose==3.3.0 # via -r requirements/_base.in python-multipart==0.0.6 - # via - # -r requirements/_base.in - # fastapi-mail + # via -r requirements/_base.in python-socketio==5.10.0 # via -r requirements/_base.in pyyaml==6.0.1 @@ -313,7 +273,6 @@ redis==5.0.1 # -c requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt # -c requirements/../../../requirements/constraints.txt # -r requirements/../../../packages/service-library/requirements/_base.in - # fakeredis referencing==0.29.3 # via # -c requirements/../../../packages/service-library/requirements/././constraints.txt @@ -356,8 +315,6 @@ sniffio==1.3.0 # anyio # httpcore # httpx -sortedcontainers==2.4.0 - # via fakeredis sqlalchemy==1.4.50 # via # -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt @@ -409,7 +366,6 @@ typing-extensions==4.8.0 # via # aiodebug # aiodocker - # aioredis # alembic # fastapi # pydantic From e250ecc347a14c828c7853c98e8c689b64b00883 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 6 Feb 2024 15:50:37 +0100 Subject: [PATCH 09/31] reqs --- services/payments/requirements/_base.in | 2 ++ services/payments/requirements/_base.txt | 26 +++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/services/payments/requirements/_base.in b/services/payments/requirements/_base.in index 5ef19fb42d5..b8f6037070c 100644 --- a/services/payments/requirements/_base.in +++ b/services/payments/requirements/_base.in @@ -14,9 +14,11 @@ --requirement ../../../packages/service-library/requirements/_fastapi.in +aiosmtplib cryptography fastapi httpx +Jinja2 packaging python-jose python-multipart diff --git a/services/payments/requirements/_base.txt b/services/payments/requirements/_base.txt index 85675b4d4e4..f51d76bdde8 100644 --- a/services/payments/requirements/_base.txt +++ b/services/payments/requirements/_base.txt @@ -37,6 +37,8 @@ aiormq==6.7.7 # via aio-pika aiosignal==1.3.1 # via aiohttp +aiosmtplib==3.0.1 + # via -r requirements/_base.in alembic==1.12.1 # via -r requirements/../../../packages/postgres-database/requirements/_base.in anyio==4.0.0 @@ -123,6 +125,7 @@ fastapi==0.99.1 # -c requirements/../../../requirements/constraints.txt # -r requirements/../../../packages/service-library/requirements/_fastapi.in # -r requirements/_base.in + # prometheus-fastapi-instrumentator frozenlist==1.4.0 # via # aiohttp @@ -158,6 +161,19 @@ idna==3.4 # email-validator # httpx # yarl +jinja2==3.1.3 + # via + # -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt + # -c requirements/../../../packages/postgres-database/requirements/../../../requirements/constraints.txt + # -c requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt + # -c requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt + # -c requirements/../../../packages/service-library/requirements/../../../requirements/constraints.txt + # -c requirements/../../../packages/service-library/requirements/./../../../packages/models-library/requirements/../../../requirements/constraints.txt + # -c requirements/../../../packages/service-library/requirements/./../../../packages/settings-library/requirements/../../../requirements/constraints.txt + # -c requirements/../../../packages/service-library/requirements/./../../../requirements/constraints.txt + # -c requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt + # -c requirements/../../../requirements/constraints.txt + # -r requirements/_base.in jsonschema==4.19.2 # via # -r requirements/../../../packages/models-library/requirements/_base.in @@ -181,7 +197,9 @@ mako==1.2.4 markdown-it-py==3.0.0 # via rich markupsafe==2.1.3 - # via mako + # via + # jinja2 + # mako mdurl==0.1.2 # via markdown-it-py multidict==6.0.4 @@ -197,6 +215,12 @@ packaging==23.2 # via -r requirements/_base.in pamqp==3.2.1 # via aiormq +prometheus-client==0.19.0 + # via + # -r requirements/../../../packages/service-library/requirements/_fastapi.in + # prometheus-fastapi-instrumentator +prometheus-fastapi-instrumentator==6.1.0 + # via -r requirements/../../../packages/service-library/requirements/_fastapi.in psycopg2-binary==2.9.9 # via sqlalchemy pyasn1==0.5.0 From 2b6bc7e9e345ba93221e55ea7b094708ff01fd12 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 6 Feb 2024 16:06:46 +0100 Subject: [PATCH 10/31] logged gather --- .../simcore_service_payments/services/notifier.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/services/payments/src/simcore_service_payments/services/notifier.py b/services/payments/src/simcore_service_payments/services/notifier.py index d1738911b27..43a0b6bcbd4 100644 --- a/services/payments/src/simcore_service_payments/services/notifier.py +++ b/services/payments/src/simcore_service_payments/services/notifier.py @@ -1,4 +1,3 @@ -import asyncio import contextlib import logging @@ -9,6 +8,7 @@ ) from models_library.users import UserID from servicelib.fastapi.app_state import SingletonInAppStateMixin +from servicelib.utils import logged_gather from ..core.settings import ApplicationSettings from ..db.payment_users_repo import PaymentsUsersRepo @@ -35,11 +35,12 @@ async def notify_payment_completed( msg = "Cannot notify incomplete payment" raise ValueError(msg) - await asyncio.gather( + await logged_gather( *( provider.notify_payment_completed(user_id=user_id, payment=payment) for provider in self.providers - ) + ), + reraise=False, ) async def notify_payment_method_acked( @@ -51,13 +52,14 @@ async def notify_payment_method_acked( msg = "Cannot notify unAcked payment-method" raise ValueError(msg) - await asyncio.gather( + await logged_gather( *( provider.notify_payment_method_acked( user_id=user_id, payment_method=payment_method ) for provider in self.providers - ) + ), + reraise=False, ) From 5974f8b27b608bbc576e65f9215a734c19f0442d Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 6 Feb 2024 18:04:06 +0100 Subject: [PATCH 11/31] adds email --- .../db/payment_users_repo.py | 3 + .../services/notifier_email.py | 224 +++++++++++++++++- 2 files changed, 220 insertions(+), 7 deletions(-) diff --git a/services/payments/src/simcore_service_payments/db/payment_users_repo.py b/services/payments/src/simcore_service_payments/db/payment_users_repo.py index 2ebb951f130..b12770462d4 100644 --- a/services/payments/src/simcore_service_payments/db/payment_users_repo.py +++ b/services/payments/src/simcore_service_payments/db/payment_users_repo.py @@ -20,3 +20,6 @@ async def get_primary_group_id(self, user_id: UserID) -> GroupID: msg = f"{user_id=} not found" raise ValueError(msg) return GroupID(row.primary_gid) + + async def get_email_info(self, user_id: UserID): + raise NotImplementedError diff --git a/services/payments/src/simcore_service_payments/services/notifier_email.py b/services/payments/src/simcore_service_payments/services/notifier_email.py index 6960ae360fb..fdc24c8e847 100644 --- a/services/payments/src/simcore_service_payments/services/notifier_email.py +++ b/services/payments/src/simcore_service_payments/services/notifier_email.py @@ -1,35 +1,245 @@ +import logging +import mimetypes +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from email.headerregistry import Address +from email.message import EmailMessage +from pathlib import Path +from typing import Final, cast + +import aiosmtplib +from attr import dataclass from models_library.api_schemas_webserver.wallets import ( PaymentMethodTransaction, PaymentTransaction, ) +from models_library.products import ProductName from models_library.users import UserID -from settings_library.email import SMTPSettings +from settings_library.email import EmailProtocol, SMTPSettings from ..db.payment_users_repo import PaymentsUsersRepo from .notifier_abc import NotificationProvider +_logger = logging.getLogger(__name__) + +_BASE_HTML: Final[ + str +] = """ + + + + + +Dear {user.first_name},
+We are delighted to confirm the successful processing of your payment of {payment.price_dollars:2.f} for the purchase of {payment.osparc_credits:2.f} credits. The credits have been added to your {product.display_name} account, and you are all set to utilize them.
+To view or download your detailed receipt, please click the button below:
+ +Should you have any questions or require further assistance, please do not hesitate to reach out to our customer support team.
+Best Regards,
+{product.display_name} support team
{product.vendor_display_inline}