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( + "

Sent via aiosmtplib

", + "html", + "utf-8", + ) + msg.attach(plain_text_message) + msg.attach(html_message) + return msg + + await smtp.send_message(compose_simple()) + await smtp.send_message(compose_multipart()) diff --git a/services/web/server/src/simcore_service_webserver/email/_core.py b/services/web/server/src/simcore_service_webserver/email/_core.py index 786a7db524e..0b02dea2fb1 100644 --- a/services/web/server/src/simcore_service_webserver/email/_core.py +++ b/services/web/server/src/simcore_service_webserver/email/_core.py @@ -8,7 +8,6 @@ from email.mime.text import MIMEText from email.utils import formatdate, make_msgid from pathlib import Path -from pprint import pformat from typing import Any, NamedTuple, TypedDict, Union import aiosmtplib @@ -22,14 +21,12 @@ def _create_smtp_client(settings: SMTPSettings) -> aiosmtplib.SMTP: - smtp_args = { - "hostname": settings.SMTP_HOST, - "port": settings.SMTP_PORT, - "use_tls": settings.SMTP_PROTOCOL == EmailProtocol.TLS, - "start_tls": settings.SMTP_PROTOCOL == EmailProtocol.STARTTLS, - } - _logger.debug("Sending email with smtp configuration: %s", pformat(smtp_args)) - return aiosmtplib.SMTP(**smtp_args) + return aiosmtplib.SMTP( + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + use_tls=settings.SMTP_PROTOCOL == EmailProtocol.TLS, + start_tls=settings.SMTP_PROTOCOL == EmailProtocol.STARTTLS, + ) async def _do_send_mail( From 5650028dcaddd1f9f06b48fe206f8612de0f212d Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 2 Feb 2024 11:29:28 +0100 Subject: [PATCH 03/31] tests send and attach --- .../unit/test_services_notifier_email.py | 116 +++++++++++++----- 1 file changed, 88 insertions(+), 28 deletions(-) diff --git a/services/payments/tests/unit/test_services_notifier_email.py b/services/payments/tests/unit/test_services_notifier_email.py index bf97ed3d423..ffb6a350220 100644 --- a/services/payments/tests/unit/test_services_notifier_email.py +++ b/services/payments/tests/unit/test_services_notifier_email.py @@ -3,9 +3,10 @@ # pylint: disable=unused-variable # pylint: disable=too-many-arguments +import mimetypes +from email.headerregistry import Address from email.message import EmailMessage -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText +from email.utils import make_msgid from pathlib import Path import aiosmtplib @@ -22,7 +23,17 @@ def tmp_environment( return setenvs_from_envfile(monkeypatch, osparc_simcore_root_dir / ".secrets") -async def test_it(tmp_environment: EnvVarsDict): +def guess_file_type(file_path: Path) -> tuple[str, str]: + assert file_path.is_file() + mimetype, _encoding = mimetypes.guess_type(file_path) + if mimetype: + maintype, subtype = mimetype.split("/", maxsplit=1) + else: + maintype, subtype = "application", "octet-stream" + return maintype, subtype + + +async def test_it(tmp_environment: EnvVarsDict, osparc_simcore_root_dir: Path): settings = SMTPSettings.create_from_envs() @@ -45,33 +56,82 @@ async def test_it(tmp_environment: EnvVarsDict): settings.SMTP_PASSWORD.get_secret_value(), ) - # Session ready to send email + def compose_email(msg: EmailMessage, text_body, html_body) -> EmailMessage: + # Text version + msg.set_content( + f"""\ + {text_body} - 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 + Done with love at Z43 + """ + ) - 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( - "

Sent via aiosmtplib

", - "html", - "utf-8", + # HTML version + logo_cid = make_msgid() + msg.add_alternative( + f"""\ + + + + {html_body} + Done with love at + + + """, + subtype="html", + ) + + 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(), + maintype=maintype, + subtype=subtype, + cid=logo_cid, + ) + + # 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, ) - msg.attach(plain_text_message) - msg.attach(html_message) return msg - await smtp.send_message(compose_simple()) - await smtp.send_message(compose_multipart()) + 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_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 = """\ + + + +
{% block content %}{% endblock %}
+ + + """ + + movies_template_html = """\ + {% extends "base.html" %} + {% block content %} +

Movies

+

+ {% 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 +] = """ + + + + + +Payment Confirmation + + + +
+ {0} +
+ + +""" + + +def _guess_file_type(file_path: Path) -> tuple[str, str]: + assert file_path.is_file() + mimetype, _encoding = mimetypes.guess_type(file_path) + if mimetype: + maintype, subtype = mimetype.split("/", maxsplit=1) + else: + maintype, subtype = "application", "octet-stream" + return maintype, subtype + + +def _add_attachments(msg: EmailMessage, file_paths: list[Path]): + for attachment_path in file_paths: + maintype, subtype = _guess_file_type(attachment_path) + msg.add_attachment( + attachment_path.read_bytes(), + filename=attachment_path.name, + maintype=maintype, + subtype=subtype, + ) + + +@asynccontextmanager +async def _create_email_session( + settings: SMTPSettings, +) -> AsyncIterator[aiosmtplib.SMTP]: + 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(), + ) + + yield cast(aiosmtplib.SMTP, smtp) + + +@dataclass +class _ProductInfo: + product_name: ProductName + display_name: str + vendor_display_inline: str + support_email: str + + +async def _compose_subject_and_content( + msg: EmailMessage, user, payment: PaymentTransaction, product: _ProductInfo +) -> EmailMessage: + # TODO: templates come here. Keep it async + + msg[ + "Subject" + ] = f"Your Payment {payment.price_dollars:2.f} USD for {payment.osparc_credits:2.f} Credits Was Successful" + + msg.set_content( + f"""\ + 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. -class EmailService: - # renders and sends emails - ... + To view or download your detailed receipt, please click the following link: {payment.invoice_url} + + If you have any questions or need further assistance, please do not hesitate to reach out to our customer support team at {product.support_email} + + Best Regards, + + {product.display_name} support team + {product.vendor_display_inline} + """ + ) + + msg.add_alternative( + _BASE_HTML.format( + f"""\ +

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:

+

View Receipt

+

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}

+ """ + ) + ) + return msg + + +async def _create_user_email( + user, payment: PaymentTransaction, product: _ProductInfo +) -> EmailMessage: + msg = EmailMessage() + msg["From"] = Address( + display_name=f"{product.display_name} support", + addr_spec=product.support_email, + ) + msg["To"] = Address( + display_name=f"{user.first_name} {user.last_name}", + addr_spec=user.email, + ) + await _compose_subject_and_content(msg, user, payment, product) + return msg class EmailProvider(NotificationProvider): # interfaces with the notification system def __init__(self, settings: SMTPSettings, users_repo: PaymentsUsersRepo): - ... + self._users_repo = users_repo + self._settings = settings + + async def on_app_startup(self): + # TODO: get templates from db upon start and not everytime + raise NotImplementedError + + async def _create_message( + self, user_id: UserID, payment: PaymentTransaction + ) -> EmailMessage: + + # retrieve info + user = await self._users_repo.get_email_info(user_id) + product = _ProductInfo( + product_name="osparc", + display_name="o²S²PARC", + vendor_display_inline="IT'IS Foundation. Zeughausstrasse 43, 8004 Zurich, Switzerland ", + support_email="support@osparc.io", + ) + + # TODO: product? via wallet_id? + # TODO: ger support email and display name + + # compose email + msg: EmailMessage = await _create_user_email( + user, payment=payment, product=product + ) + + return msg async def notify_payment_completed( self, user_id: UserID, payment: PaymentTransaction, ): + msg = await self._create_message(user_id, payment) - raise NotImplementedError + async with _create_email_session(self._settings) as smtp: + await smtp.send_message(msg) async def notify_payment_method_acked( self, user_id: UserID, payment_method: PaymentMethodTransaction, ): - raise NotImplementedError + assert user_id # nosec + assert payment_method # nosec + _logger.debug("Nothing to send here") From e45c4e9edf29ac0a6e20aa68fc6d2ee4b7a45592 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 6 Feb 2024 20:30:57 +0100 Subject: [PATCH 12/31] adds templates --- services/payments/requirements/_base.in | 6 +- .../services/notifier_email.py | 209 ++++++++++-------- 2 files changed, 119 insertions(+), 96 deletions(-) diff --git a/services/payments/requirements/_base.in b/services/payments/requirements/_base.in index b8f6037070c..da3813cc2bb 100644 --- a/services/payments/requirements/_base.in +++ b/services/payments/requirements/_base.in @@ -14,14 +14,14 @@ --requirement ../../../packages/service-library/requirements/_fastapi.in -aiosmtplib +aiosmtplib # notifier cryptography fastapi httpx -Jinja2 +Jinja2 # notifier packaging python-jose python-multipart -python-socketio +python-socketio # notifier typer[all] uvicorn[standard] 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 fdc24c8e847..5282ae86267 100644 --- a/services/payments/src/simcore_service_payments/services/notifier_email.py +++ b/services/payments/src/simcore_service_payments/services/notifier_email.py @@ -9,6 +9,7 @@ import aiosmtplib from attr import dataclass +from jinja2 import DictLoader, Environment, select_autoescape from models_library.api_schemas_webserver.wallets import ( PaymentMethodTransaction, PaymentTransaction, @@ -22,6 +23,7 @@ _logger = logging.getLogger(__name__) + _BASE_HTML: Final[ str ] = """ @@ -30,7 +32,7 @@ -Payment Confirmation +{% block title %}{% endblock %} -
- {0} -
+ {% block content %} + {% endblock %} """ +_NOTIFY_PAYMENTS_HTML = """ +{% extends 'base.html' %} + +{% block title %}Payment Confirmation{% endblock %} + +{% block content %} +
+

Dear {{ user.first_name }},

+

We are delighted to confirm the successful processing of your payment of {{ payment.price_dollars }} USD for the purchase of {{ payment.osparc_credits }} credits. The credits have been added to your {{ product.display_name }} account, and you are all set to utilize them.

+

For more details you can view or download your receipt

+

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

+
+{% endblock %} +""" + +_NOTIFY_PAYMENTS_TXT = """ + Dear {{ user.first_name }}, + + We are delighted to confirm the successful processing of your payment of **{{ payment.price_dollars }}** *USD* for the purchase of **{{ payment.osparc_credits }}** *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 following link {{ payment.invoice_url }} + + Should you have any questions or require further assistance, please do not hesitate to reach out to our {{ product.support_email }}" customer support team. + Best Regards, + + {{ product.display_name }} support team + {{ product.vendor_display_inline }} +""" + + +_PRODUCT_NOTIFICATIONS_TEMPLATES = { + "base.html": _BASE_HTML, + "notify_payments.html": _NOTIFY_PAYMENTS_HTML, + "notify_payments.txt": _NOTIFY_PAYMENTS_TXT, +} + + +@dataclass +class _UserData: + first_name: str + last_name: str + email: str + + +@dataclass +class _ProductData: + product_name: ProductName + display_name: str + vendor_display_inline: str + support_email: str + + +@dataclass +class _PaymentData: + price_dollars: str + osparc_credits: str + def _guess_file_type(file_path: Path) -> tuple[str, str]: assert file_path.is_file() @@ -100,6 +140,47 @@ def _add_attachments(msg: EmailMessage, file_paths: list[Path]): ) +async def _create_user_email( + env: Environment, + user: _UserData, + payment: PaymentTransaction, + product: _ProductData, +) -> EmailMessage: + msg = EmailMessage() + + # from/to + msg["From"] = Address( + display_name=f"{product.display_name} support", + addr_spec=product.support_email, + ) + msg["To"] = Address( + display_name=f"{user.first_name} {user.last_name}", + addr_spec=user.email, + ) + + # subject + msg[ + "Subject" + ] = f"Your Payment {payment.price_dollars:.2f} USD for {payment.osparc_credits:.2f} Credits Was Successful" + + # body + data = { + "user": user, + "product": product, + "payment": _PaymentData( + price_dollars=f"{payment.price_dollars:.2f}", + osparc_credits=f"{payment.osparc_credits:.2f}", + ), + } + + text_template = env.get_template("notify_payments.txt") + msg.set_content(text_template.render(data)) + + html_template = env.get_template("notify_payments.html") + msg.add_alternative(html_template.render(data), subtype="html") + return msg + + @asynccontextmanager async def _create_email_session( settings: SMTPSettings, @@ -126,78 +207,17 @@ async def _create_email_session( yield cast(aiosmtplib.SMTP, smtp) -@dataclass -class _ProductInfo: - product_name: ProductName - display_name: str - vendor_display_inline: str - support_email: str - - -async def _compose_subject_and_content( - msg: EmailMessage, user, payment: PaymentTransaction, product: _ProductInfo -) -> EmailMessage: - # TODO: templates come here. Keep it async - - msg[ - "Subject" - ] = f"Your Payment {payment.price_dollars:2.f} USD for {payment.osparc_credits:2.f} Credits Was Successful" - - msg.set_content( - f"""\ - 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 following link: {payment.invoice_url} - - If you have any questions or need further assistance, please do not hesitate to reach out to our customer support team at {product.support_email} - - Best Regards, - - {product.display_name} support team - {product.vendor_display_inline} - """ - ) - - msg.add_alternative( - _BASE_HTML.format( - f"""\ -

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:

-

View Receipt

-

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}

- """ - ) - ) - return msg - - -async def _create_user_email( - user, payment: PaymentTransaction, product: _ProductInfo -) -> EmailMessage: - msg = EmailMessage() - msg["From"] = Address( - display_name=f"{product.display_name} support", - addr_spec=product.support_email, - ) - msg["To"] = Address( - display_name=f"{user.first_name} {user.last_name}", - addr_spec=user.email, - ) - await _compose_subject_and_content(msg, user, payment, product) - return msg - - class EmailProvider(NotificationProvider): # interfaces with the notification system def __init__(self, settings: SMTPSettings, users_repo: PaymentsUsersRepo): self._users_repo = users_repo self._settings = settings + self._jinja_env = Environment( + loader=DictLoader(_PRODUCT_NOTIFICATIONS_TEMPLATES), + autoescape=select_autoescape(["html", "xml"]), + ) + async def on_app_startup(self): # TODO: get templates from db upon start and not everytime raise NotImplementedError @@ -208,7 +228,7 @@ async def _create_message( # retrieve info user = await self._users_repo.get_email_info(user_id) - product = _ProductInfo( + product = _ProductData( product_name="osparc", display_name="o²S²PARC", vendor_display_inline="IT'IS Foundation. Zeughausstrasse 43, 8004 Zurich, Switzerland ", @@ -220,7 +240,10 @@ async def _create_message( # compose email msg: EmailMessage = await _create_user_email( - user, payment=payment, product=product + self._jinja_env, + user=user, + payment=payment, + product=product, ) return msg @@ -242,4 +265,4 @@ async def notify_payment_method_acked( ): assert user_id # nosec assert payment_method # nosec - _logger.debug("Nothing to send here") + _logger.debug("No email sent when payment method is acked") From f94a072d4ed0da7c3f343b2ae0b41e1afdadeb92 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 6 Feb 2024 20:34:42 +0100 Subject: [PATCH 13/31] draft tests --- .../unit/test_services_notifier_email.py | 161 ++++++++---------- 1 file changed, 75 insertions(+), 86 deletions(-) diff --git a/services/payments/tests/unit/test_services_notifier_email.py b/services/payments/tests/unit/test_services_notifier_email.py index 27c4e5d5e1c..67e80ab136a 100644 --- a/services/payments/tests/unit/test_services_notifier_email.py +++ b/services/payments/tests/unit/test_services_notifier_email.py @@ -4,86 +4,31 @@ # pylint: disable=too-many-arguments import base64 -import mimetypes -from contextlib import asynccontextmanager from email.headerregistry import Address from email.message import EmailMessage from email.utils import make_msgid from pathlib import Path -import aiosmtplib +import arrow import pytest +from faker import Faker +from jinja2 import DictLoader, Environment, select_autoescape +from models_library.api_schemas_webserver.wallets import PaymentTransaction 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") - - -def guess_file_type(file_path: Path) -> tuple[str, str]: - assert file_path.is_file() - mimetype, _encoding = mimetypes.guess_type(file_path) - if mimetype: - maintype, subtype = mimetype.split("/", maxsplit=1) - else: - maintype, subtype = "application", "octet-stream" - return maintype, subtype - - -@asynccontextmanager -async def email_session(settings: SMTPSettings): - 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(), - ) - - yield smtp - - -async def test_it(tmp_environment: EnvVarsDict, osparc_simcore_root_dir: Path): - - settings = SMTPSettings.create_from_envs() - - base_template_html = """\ - - - -
{% block content %}{% endblock %}
- - - """ - - movies_template_html = """\ - {% extends "base.html" %} - {% block content %} -

Movies

-

- {% for movie in data['movies'] %} {%if movie['title']!="Terminator" %} - {{ movie['title'] }} - {% endif %} {% endfor %} -

- {% endblock %} - """ - +from settings_library.email import SMTPSettings +from simcore_service_payments.services.notifier_email import ( + _PRODUCT_NOTIFICATIONS_TEMPLATES, + _add_attachments, + _create_email_session, + _create_user_email, + _guess_file_type, + _ProductData, + _UserData, +) + + +def run_trials(osparc_simcore_root_dir: Path): def compose_branded_email( msg: EmailMessage, text_body, html_body, attachments: list[Path] ) -> EmailMessage: @@ -126,7 +71,7 @@ def compose_branded_email( assert msg.is_multipart() - maintype, subtype = guess_file_type(logo_path) + maintype, subtype = _guess_file_type(logo_path) msg.get_payload(1).add_related( logo_path.read_bytes(), maintype=maintype, @@ -135,24 +80,13 @@ def compose_branded_email( ) # 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, - ) + _add_attachments(msg, attachments) 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( - display_name="Pedro Crespo-Valero", addr_spec="crespo@speag.com" - ) msg["Subject"] = "Payment invoice" - text_body = """\ Hi there, @@ -171,7 +105,62 @@ def compose_branded_email( msg, text_body, html_body, attachments=[osparc_simcore_root_dir / "ignore.pdf"] ) - async with email_session(settings) as smtp: + +@pytest.fixture +def tmp_environment( + osparc_simcore_root_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> EnvVarsDict: + return setenvs_from_envfile(monkeypatch, osparc_simcore_root_dir / ".secrets") + + +@pytest.mark.skip(reason="DEV ONLY") +async def test_it( + tmp_environment: EnvVarsDict, + osparc_simcore_root_dir: Path, + tmp_path: Path, + faker: Faker, +): + + settings = SMTPSettings.create_from_envs() + env = Environment( + loader=DictLoader(_PRODUCT_NOTIFICATIONS_TEMPLATES), + autoescape=select_autoescape(["html", "xml"]), + ) + user = _UserData( + first_name="Pedro", last_name="Crespo-Valero", email="crespo@speag.com" + ) + + product = _ProductData( + product_name="osparc", + display_name="o²S²PARC", + vendor_display_inline="IT'IS Foundation. Zeughausstrasse 43, 8004 Zurich, Switzerland ", + support_email="support@osparc.io", + ) + payment = PaymentTransaction( + payment_id="pt_123234", + price_dollars=12.345, + wallet_id=12, + osparc_credits=12.345, + comment="fake", + created_at=arrow.now().datetime, + completed_at=arrow.now().datetime, + completedStatus="SUCCESS", + state_message="ok", + invoice_url="https://invoices.com?id=pt_123234", + ) + + msg = await _create_user_email(env, user, payment, product) + + attachment = tmp_path / "attachment.txt" + attachment.write_text(faker.text()) + _add_attachments( + msg, + [ + attachment, + ], + ) + + async with _create_email_session(settings) as smtp: await smtp.send_message(msg) # render a template From 4acbd596eff0fca7515ab14aee70fd3100f8b9bd Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 6 Feb 2024 20:48:43 +0100 Subject: [PATCH 14/31] fixes template --- .../services/notifier_email.py | 2 ++ .../unit/test_services_notifier_email.py | 33 +++++++++---------- 2 files changed, 17 insertions(+), 18 deletions(-) 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 5282ae86267..c60fc4393b0 100644 --- a/services/payments/src/simcore_service_payments/services/notifier_email.py +++ b/services/payments/src/simcore_service_payments/services/notifier_email.py @@ -117,6 +117,7 @@ class _ProductData: class _PaymentData: price_dollars: str osparc_credits: str + invoice_url: str def _guess_file_type(file_path: Path) -> tuple[str, str]: @@ -170,6 +171,7 @@ async def _create_user_email( "payment": _PaymentData( price_dollars=f"{payment.price_dollars:.2f}", osparc_credits=f"{payment.osparc_credits:.2f}", + invoice_url=payment.invoice_url, ), } diff --git a/services/payments/tests/unit/test_services_notifier_email.py b/services/payments/tests/unit/test_services_notifier_email.py index 67e80ab136a..67fec5abff4 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 base64 +from decimal import Decimal from email.headerregistry import Address from email.message import EmailMessage from email.utils import make_msgid @@ -113,22 +114,28 @@ def tmp_environment( return setenvs_from_envfile(monkeypatch, osparc_simcore_root_dir / ".secrets") +@pytest.fixture +def user( + faker: Faker, +): + return _UserData( + first_name="Pedrolito", last_name="Crespo", email="crespo@itis.swiss" + ) + + @pytest.mark.skip(reason="DEV ONLY") async def test_it( tmp_environment: EnvVarsDict, osparc_simcore_root_dir: Path, tmp_path: Path, faker: Faker, + user: _UserData, ): - settings = SMTPSettings.create_from_envs() env = Environment( loader=DictLoader(_PRODUCT_NOTIFICATIONS_TEMPLATES), autoescape=select_autoescape(["html", "xml"]), ) - user = _UserData( - first_name="Pedro", last_name="Crespo-Valero", email="crespo@speag.com" - ) product = _ProductData( product_name="osparc", @@ -138,32 +145,22 @@ async def test_it( ) payment = PaymentTransaction( payment_id="pt_123234", - price_dollars=12.345, + price_dollars=Decimal(1), wallet_id=12, - osparc_credits=12.345, + osparc_credits=Decimal(10), comment="fake", created_at=arrow.now().datetime, completed_at=arrow.now().datetime, completedStatus="SUCCESS", state_message="ok", - invoice_url="https://invoices.com?id=pt_123234", + invoice_url=faker.image_url(), ) msg = await _create_user_email(env, user, payment, product) attachment = tmp_path / "attachment.txt" attachment.write_text(faker.text()) - _add_attachments( - msg, - [ - attachment, - ], - ) + _add_attachments(msg, [attachment]) async with _create_email_session(settings) as smtp: await smtp.send_message(msg) - - # render a template - # common CSS+HTML - - # compose simple email for From cd209b86f235f2f53aaf661018d409bf08bc89c9 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 7 Feb 2024 00:02:55 +0100 Subject: [PATCH 15/31] tests w/ and w/o externals --- services/payments/tests/unit/conftest.py | 7 + .../unit/test_services_notifier_email.py | 144 ++++++------------ 2 files changed, 53 insertions(+), 98 deletions(-) diff --git a/services/payments/tests/unit/conftest.py b/services/payments/tests/unit/conftest.py index 23c67c3a64c..51b4f2bd09a 100644 --- a/services/payments/tests/unit/conftest.py +++ b/services/payments/tests/unit/conftest.py @@ -386,6 +386,13 @@ def pytest_addoption(parser: pytest.Parser): default=None, help="Path to an env file. Consider passing a link to repo configs, i.e. `ln -s /path/to/osparc-ops-config/repo.config`", ) + group.addoption( + "--external-email", + action="store", + type=str, + default=None, + help="An email for test_services_notifier_email", + ) @pytest.fixture(scope="session") diff --git a/services/payments/tests/unit/test_services_notifier_email.py b/services/payments/tests/unit/test_services_notifier_email.py index 67fec5abff4..b315fa12397 100644 --- a/services/payments/tests/unit/test_services_notifier_email.py +++ b/services/payments/tests/unit/test_services_notifier_email.py @@ -3,11 +3,6 @@ # pylint: disable=unused-variable # pylint: disable=too-many-arguments -import base64 -from decimal import Decimal -from email.headerregistry import Address -from email.message import EmailMessage -from email.utils import make_msgid from pathlib import Path import arrow @@ -15,122 +10,73 @@ from faker import Faker from jinja2 import DictLoader, Environment, select_autoescape from models_library.api_schemas_webserver.wallets import PaymentTransaction +from pytest_mock import MockerFixture from pytest_simcore.helpers.typing_env import EnvVarsDict -from pytest_simcore.helpers.utils_envs import setenvs_from_envfile +from pytest_simcore.helpers.utils_envs import setenvs_from_dict from settings_library.email import SMTPSettings from simcore_service_payments.services.notifier_email import ( _PRODUCT_NOTIFICATIONS_TEMPLATES, _add_attachments, _create_email_session, _create_user_email, - _guess_file_type, _ProductData, _UserData, ) -def run_trials(osparc_simcore_root_dir: Path): - def compose_branded_email( - msg: EmailMessage, text_body, html_body, attachments: list[Path] - ) -> EmailMessage: - # Text version - msg.set_content( - f"""\ - {text_body} - - Done with love at Z43 - """ - ) - - # 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 - - - """, - subtype="html", - ) - - assert msg.is_multipart() - - 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 - _add_attachments(msg, attachments) - 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["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"] +@pytest.fixture +def app_environment( + monkeypatch: pytest.MonkeyPatch, + external_environment: EnvVarsDict, + docker_compose_service_payments_env_vars: EnvVarsDict, +) -> EnvVarsDict: + return setenvs_from_dict( + monkeypatch, + { + **docker_compose_service_payments_env_vars, + **external_environment, + }, ) -@pytest.fixture -def tmp_environment( - osparc_simcore_root_dir: Path, monkeypatch: pytest.MonkeyPatch -) -> EnvVarsDict: - return setenvs_from_envfile(monkeypatch, osparc_simcore_root_dir / ".secrets") +@pytest.fixture(scope="session") +def external_email(request: pytest.FixtureRequest) -> str | None: + return request.config.getoption("--external-email", default=None) @pytest.fixture -def user( - faker: Faker, -): +def user(faker: Faker, external_email: str | None): + email = faker.email() + if external_email: + print("📧 EXTERNAL using in test", f"{external_email=}") + email = external_email + return _UserData( - first_name="Pedrolito", last_name="Crespo", email="crespo@itis.swiss" + first_name=faker.first_name(), + last_name=faker.last_name(), + email=email, ) -@pytest.mark.skip(reason="DEV ONLY") -async def test_it( - tmp_environment: EnvVarsDict, - osparc_simcore_root_dir: Path, +async def test_send_email_workflow( + app_environment: EnvVarsDict, tmp_path: Path, faker: Faker, user: _UserData, + external_email: str | None, + mocker: MockerFixture, ): + """ + Example of usage with external email and envfile + + > pytest --external-email=me@email.me --external-envfile=.myenv -k test_send_email_workflow --pdb tests/unit + """ + + if not external_email: + mocker.patch( + "simcore_service_payments.services.notifier_email._create_email_session" + ) + settings = SMTPSettings.create_from_envs() env = Environment( loader=DictLoader(_PRODUCT_NOTIFICATIONS_TEMPLATES), @@ -145,9 +91,9 @@ async def test_it( ) payment = PaymentTransaction( payment_id="pt_123234", - price_dollars=Decimal(1), + price_dollars=faker.pydecimal(positive=True, right_digits=2, left_digits=4), wallet_id=12, - osparc_credits=Decimal(10), + osparc_credits=faker.pydecimal(positive=True, right_digits=2, left_digits=4), comment="fake", created_at=arrow.now().datetime, completed_at=arrow.now().datetime, @@ -158,9 +104,11 @@ async def test_it( msg = await _create_user_email(env, user, payment, product) - attachment = tmp_path / "attachment.txt" + attachment = tmp_path / "test-attachment.txt" attachment.write_text(faker.text()) _add_attachments(msg, [attachment]) + print(msg) + async with _create_email_session(settings) as smtp: await smtp.send_message(msg) From 18ce334c25e6b0658e3b8e0ad5c7cd41825dd871 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 7 Feb 2024 00:07:59 +0100 Subject: [PATCH 16/31] adds email env --- .env-devel | 4 +-- services/docker-compose.yml | 54 ++++++++++++++++++++----------------- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/.env-devel b/.env-devel index 5a2b72675bc..4870cdd6d78 100644 --- a/.env-devel +++ b/.env-devel @@ -83,8 +83,9 @@ PAYMENTS_ACCESS_TOKEN_EXPIRE_MINUTES=30 PAYMENTS_ACCESS_TOKEN_SECRET_KEY=2c0411810565e063309be1457009fb39ce023946f6a354e6935107b57676 PAYMENTS_AUTORECHARGE_DEFAULT_MONTHLY_LIMIT=10000 PAYMENTS_AUTORECHARGE_DEFAULT_TOP_UP_AMOUNT=100.0 -PAYMENTS_AUTORECHARGE_MIN_BALANCE_IN_CREDITS=100 PAYMENTS_AUTORECHARGE_ENABLED=1 +PAYMENTS_AUTORECHARGE_MIN_BALANCE_IN_CREDITS=100 +PAYMENTS_EMAIL={} PAYMENTS_FAKE_COMPLETION_DELAY_SEC=10 PAYMENTS_FAKE_COMPLETION=0 PAYMENTS_GATEWAY_API_SECRET=adminadmin @@ -96,7 +97,6 @@ PAYMENTS_PORT=8000 PAYMENTS_SWAGGER_API_DOC_ENABLED=1 PAYMENTS_USERNAME=admin - POSTGRES_DB=simcoredb POSTGRES_ENDPOINT=postgres:5432 POSTGRES_HOST=postgres diff --git a/services/docker-compose.yml b/services/docker-compose.yml index b8342e1837f..10a23cb7cef 100644 --- a/services/docker-compose.yml +++ b/services/docker-compose.yml @@ -354,30 +354,36 @@ services: networks: - default environment: - - LOG_FORMAT_LOCAL_DEV_ENABLED=${LOG_FORMAT_LOCAL_DEV_ENABLED} - - PAYMENTS_ACCESS_TOKEN_EXPIRE_MINUTES=${PAYMENTS_ACCESS_TOKEN_EXPIRE_MINUTES} - - PAYMENTS_ACCESS_TOKEN_SECRET_KEY=${PAYMENTS_ACCESS_TOKEN_SECRET_KEY} - - PAYMENTS_AUTORECHARGE_DEFAULT_MONTHLY_LIMIT=${PAYMENTS_AUTORECHARGE_DEFAULT_MONTHLY_LIMIT} - - PAYMENTS_AUTORECHARGE_DEFAULT_TOP_UP_AMOUNT=${PAYMENTS_AUTORECHARGE_DEFAULT_TOP_UP_AMOUNT} - - PAYMENTS_AUTORECHARGE_MIN_BALANCE_IN_CREDITS=${PAYMENTS_AUTORECHARGE_MIN_BALANCE_IN_CREDITS} - - PAYMENTS_AUTORECHARGE_ENABLED=${PAYMENTS_AUTORECHARGE_ENABLED} - - PAYMENTS_GATEWAY_API_SECRET=${PAYMENTS_GATEWAY_API_SECRET} - - PAYMENTS_GATEWAY_URL=${PAYMENTS_GATEWAY_URL} - - PAYMENTS_LOGLEVEL=${PAYMENTS_LOGLEVEL} - - PAYMENTS_PASSWORD=${PAYMENTS_PASSWORD} - - PAYMENTS_SWAGGER_API_DOC_ENABLED=${PAYMENTS_SWAGGER_API_DOC_ENABLED} - - PAYMENTS_USERNAME=${PAYMENTS_USERNAME} - - POSTGRES_DB=${POSTGRES_DB} - - POSTGRES_HOST=${POSTGRES_HOST} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_PORT=${POSTGRES_PORT} - - POSTGRES_USER=${POSTGRES_USER} - - RABBIT_HOST=${RABBIT_HOST} - - RABBIT_PASSWORD=${RABBIT_PASSWORD} - - RABBIT_PORT=${RABBIT_PORT} - - RABBIT_SECURE=${RABBIT_SECURE} - - RABBIT_USER=${RABBIT_USER} - - RESOURCE_USAGE_TRACKER_HOST=${RESOURCE_USAGE_TRACKER_HOST} + LOG_FORMAT_LOCAL_DEV_ENABLED: ${LOG_FORMAT_LOCAL_DEV_ENABLED} + PAYMENTS_ACCESS_TOKEN_EXPIRE_MINUTES: ${PAYMENTS_ACCESS_TOKEN_EXPIRE_MINUTES} + PAYMENTS_ACCESS_TOKEN_SECRET_KEY: ${PAYMENTS_ACCESS_TOKEN_SECRET_KEY} + PAYMENTS_AUTORECHARGE_DEFAULT_MONTHLY_LIMIT: ${PAYMENTS_AUTORECHARGE_DEFAULT_MONTHLY_LIMIT} + PAYMENTS_AUTORECHARGE_DEFAULT_TOP_UP_AMOUNT: ${PAYMENTS_AUTORECHARGE_DEFAULT_TOP_UP_AMOUNT} + PAYMENTS_AUTORECHARGE_ENABLED: ${PAYMENTS_AUTORECHARGE_ENABLED} + PAYMENTS_AUTORECHARGE_MIN_BALANCE_IN_CREDITS: ${PAYMENTS_AUTORECHARGE_MIN_BALANCE_IN_CREDITS} + PAYMENTS_GATEWAY_API_SECRET: ${PAYMENTS_GATEWAY_API_SECRET} + PAYMENTS_GATEWAY_URL: ${PAYMENTS_GATEWAY_URL} + PAYMENTS_LOGLEVEL: ${PAYMENTS_LOGLEVEL} + PAYMENTS_PASSWORD: ${PAYMENTS_PASSWORD} + PAYMENTS_SWAGGER_API_DOC_ENABLED: ${PAYMENTS_SWAGGER_API_DOC_ENABLED} + PAYMENTS_USERNAME: ${PAYMENTS_USERNAME} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_HOST: ${POSTGRES_HOST} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_USER: ${POSTGRES_USER} + RABBIT_HOST: ${RABBIT_HOST} + RABBIT_PASSWORD: ${RABBIT_PASSWORD} + RABBIT_PORT: ${RABBIT_PORT} + RABBIT_SECURE: ${RABBIT_SECURE} + RABBIT_USER: ${RABBIT_USER} + RESOURCE_USAGE_TRACKER_HOST: ${RESOURCE_USAGE_TRACKER_HOST} + PAYMENTS_EMAIL: ${PAYMENTS_EMAIL} + SMTP_HOST: ${SMTP_HOST} + SMTP_PASSWORD: ${SMTP_PASSWORD} + SMTP_PORT: ${SMTP_PORT} + SMTP_PROTOCOL: ${SMTP_PROTOCOL} + SMTP_USERNAME: ${SMTP_USERNAME} resource-usage-tracker: image: ${DOCKER_REGISTRY:-itisfoundation}/resource-usage-tracker:${DOCKER_IMAGE_TAG:-latest} From 517c8150474fea215a153fa3a82ea9d7936db4e1 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 7 Feb 2024 00:24:05 +0100 Subject: [PATCH 17/31] adapst email --- .../services/notifier_email.py | 8 ++++---- services/payments/tests/conftest.py | 11 +++++------ .../tests/unit/test_services_notifier_email.py | 12 +++++++----- 3 files changed, 16 insertions(+), 15 deletions(-) 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 c60fc4393b0..79d4f54cc11 100644 --- a/services/payments/src/simcore_service_payments/services/notifier_email.py +++ b/services/payments/src/simcore_service_payments/services/notifier_email.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Final, cast -import aiosmtplib +from aiosmtplib import SMTP from attr import dataclass from jinja2 import DictLoader, Environment, select_autoescape from models_library.api_schemas_webserver.wallets import ( @@ -186,8 +186,8 @@ async def _create_user_email( @asynccontextmanager async def _create_email_session( settings: SMTPSettings, -) -> AsyncIterator[aiosmtplib.SMTP]: - async with aiosmtplib.SMTP( +) -> AsyncIterator[SMTP]: + async with SMTP( hostname=settings.SMTP_HOST, port=settings.SMTP_PORT, # FROM https://aiosmtplib.readthedocs.io/en/stable/usage.html#starttls-connections @@ -206,7 +206,7 @@ async def _create_email_session( settings.SMTP_PASSWORD.get_secret_value(), ) - yield cast(aiosmtplib.SMTP, smtp) + yield cast(SMTP, smtp) class EmailProvider(NotificationProvider): diff --git a/services/payments/tests/conftest.py b/services/payments/tests/conftest.py index 19f7c5a682c..3169d1d31e0 100644 --- a/services/payments/tests/conftest.py +++ b/services/payments/tests/conftest.py @@ -76,8 +76,7 @@ def docker_compose_service_payments_env_vars( "payments" ] - def _substitute(item): - key, value = item.split("=") + def _substitute(key, value): if m := re.match(r"\${([^{}:-]\w+)", value): expected_env_var = m.group(1) try: @@ -92,10 +91,10 @@ def _substitute(item): return None envs: EnvVarsDict = {} - for item in payments.get("environment", []): - if found := _substitute(item): - key, value = found - envs[key] = value + for key, value in payments.get("environment", {}).items(): + if found := _substitute(key, value): + _, new_value = found + envs[key] = new_value return envs diff --git a/services/payments/tests/unit/test_services_notifier_email.py b/services/payments/tests/unit/test_services_notifier_email.py index b315fa12397..b458c4b1905 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 from pathlib import Path +from unittest.mock import AsyncMock import arrow import pytest @@ -73,9 +74,7 @@ async def test_send_email_workflow( """ if not external_email: - mocker.patch( - "simcore_service_payments.services.notifier_email._create_email_session" - ) + mocker.patch("simcore_service_payments.services.notifier_email.SMTP") settings = SMTPSettings.create_from_envs() env = Environment( @@ -108,7 +107,10 @@ async def test_send_email_workflow( attachment.write_text(faker.text()) _add_attachments(msg, [attachment]) - print(msg) - async with _create_email_session(settings) as smtp: await smtp.send_message(msg) + + if not external_email: + assert isinstance(smtp, AsyncMock) + assert smtp.login.called + assert smtp.send_message.called From 3fee99c94d27d955e16808a0bb70a52928a7e1ee Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 7 Feb 2024 00:30:55 +0100 Subject: [PATCH 18/31] minor --- .../src/simcore_service_payments/services/notifier_email.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 79d4f54cc11..246a8abe7e5 100644 --- a/services/payments/src/simcore_service_payments/services/notifier_email.py +++ b/services/payments/src/simcore_service_payments/services/notifier_email.py @@ -199,8 +199,8 @@ async def _create_email_session( start_tls=settings.SMTP_PROTOCOL == EmailProtocol.STARTTLS, ) as smtp: if settings.has_credentials: - assert settings.SMTP_USERNAME - assert settings.SMTP_PASSWORD + assert settings.SMTP_USERNAME # nosec + assert settings.SMTP_PASSWORD # nosec await smtp.login( settings.SMTP_USERNAME, settings.SMTP_PASSWORD.get_secret_value(), From 9012d21af024f866c2408b79ba8fa11d08ce0972 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 7 Feb 2024 18:41:52 +0100 Subject: [PATCH 19/31] new repo get_notification_data --- .../db/payment_users_repo.py | 60 +++++++++++++++---- 1 file changed, 50 insertions(+), 10 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 b12770462d4..0281b97eb22 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 @@ -1,5 +1,8 @@ import sqlalchemy as sa +from models_library.api_schemas_webserver.wallets import PaymentID from models_library.users import GroupID, UserID +from simcore_postgres_database.models.payments_transactions import payments_transactions +from simcore_postgres_database.models.products import products from simcore_postgres_database.models.users import users from .base import BaseRepository @@ -10,16 +13,53 @@ class PaymentsUsersRepo(BaseRepository): # Currently linked to `users` but expected to be linked to `payments_users` # when databases are separated. The latter will be a subset copy of the former. # - async def get_primary_group_id(self, user_id: UserID) -> GroupID: + + async def _get(self, query): async with self.db_engine.begin() as conn: - result = await conn.execute( - sa.select(users.c.primary_gid).where(users.c.id == user_id) - ) - row = result.first() - if row is None: - msg = f"{user_id=} not found" - raise ValueError(msg) + result = await conn.execute(query) + return result.first() + + async def get_primary_group_id(self, user_id: UserID) -> GroupID: + if row := await self._get( + sa.select( + users.c.primary_gid, + ).where(users.c.id == user_id) + ): return GroupID(row.primary_gid) - async def get_email_info(self, user_id: UserID): - raise NotImplementedError + msg = f"{user_id=} not found" + raise ValueError(msg) + + async def get_notification_data(self, user_id: UserID, payment_id: PaymentID): + """Retrives data that will be injected in a notification for the user on this payment""" + if row := await self._get( + sa.select( + payments_transactions.c.payment_id, + users.c.first_name, + users.c.last_name, + users.c.email, + products.c.product_name, + products.c.display_name, + products.c.vendor, + products.c.support_email, + ) + .select_from( + sa.join( + sa.join( + payments_transactions, + users, + payments_transactions.c.user_id == users.c.id, + ), + products, + payments_transactions.c.product_name == products.c.name, + ) + ) + .where( + (payments_transactions.c.payment_id == payment_id) + & (payments_transactions.c.user_id == user_id) + ) + ): + return row + + msg = f"{payment_id=} for {user_id=} was not found" + raise ValueError(msg) From d0cde75ef80ead85e892afac25b4f271542c2daa Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 7 Feb 2024 18:42:58 +0100 Subject: [PATCH 20/31] uses new entry to retrieve data --- .../services/notifier_email.py | 92 +++++++++---------- 1 file changed, 44 insertions(+), 48 deletions(-) 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 246a8abe7e5..79591305e66 100644 --- a/services/payments/src/simcore_service_payments/services/notifier_email.py +++ b/services/payments/src/simcore_service_payments/services/notifier_email.py @@ -59,6 +59,7 @@ """ + _NOTIFY_PAYMENTS_HTML = """ {% extends 'base.html' %} @@ -91,10 +92,14 @@ """ +_NOTIFY_PAYMENTS_SUBJECT = "Your Payment {{ payment.price_dollars }} USD for {{ payment.osparc_credits }} Credits Was Successful" + + _PRODUCT_NOTIFICATIONS_TEMPLATES = { "base.html": _BASE_HTML, "notify_payments.html": _NOTIFY_PAYMENTS_HTML, "notify_payments.txt": _NOTIFY_PAYMENTS_TXT, + "notify_payments_subject.txt": _NOTIFY_PAYMENTS_SUBJECT, } @@ -120,36 +125,14 @@ class _PaymentData: invoice_url: str -def _guess_file_type(file_path: Path) -> tuple[str, str]: - assert file_path.is_file() - mimetype, _encoding = mimetypes.guess_type(file_path) - if mimetype: - maintype, subtype = mimetype.split("/", maxsplit=1) - else: - maintype, subtype = "application", "octet-stream" - return maintype, subtype - - -def _add_attachments(msg: EmailMessage, file_paths: list[Path]): - for attachment_path in file_paths: - maintype, subtype = _guess_file_type(attachment_path) - msg.add_attachment( - attachment_path.read_bytes(), - filename=attachment_path.name, - maintype=maintype, - subtype=subtype, - ) - - async def _create_user_email( env: Environment, user: _UserData, - payment: PaymentTransaction, + payment: _PaymentData, product: _ProductData, ) -> EmailMessage: msg = EmailMessage() - # from/to msg["From"] = Address( display_name=f"{product.display_name} support", addr_spec=product.support_email, @@ -159,20 +142,13 @@ async def _create_user_email( addr_spec=user.email, ) - # subject - msg[ - "Subject" - ] = f"Your Payment {payment.price_dollars:.2f} USD for {payment.osparc_credits:.2f} Credits Was Successful" + msg["Subject"] = env.get_template("notify_payments_subject.txt").render(data) # body data = { "user": user, "product": product, - "payment": _PaymentData( - price_dollars=f"{payment.price_dollars:.2f}", - osparc_credits=f"{payment.osparc_credits:.2f}", - invoice_url=payment.invoice_url, - ), + "payment": payment, } text_template = env.get_template("notify_payments.txt") @@ -183,6 +159,27 @@ async def _create_user_email( return msg +def _guess_file_type(file_path: Path) -> tuple[str, str]: + assert file_path.is_file() + mimetype, _encoding = mimetypes.guess_type(file_path) + if mimetype: + maintype, subtype = mimetype.split("/", maxsplit=1) + else: + maintype, subtype = "application", "octet-stream" + return maintype, subtype + + +def _add_attachments(msg: EmailMessage, file_paths: list[Path]): + for attachment_path in file_paths: + maintype, subtype = _guess_file_type(attachment_path) + msg.add_attachment( + attachment_path.read_bytes(), + filename=attachment_path.name, + maintype=maintype, + subtype=subtype, + ) + + @asynccontextmanager async def _create_email_session( settings: SMTPSettings, @@ -210,7 +207,6 @@ async def _create_email_session( class EmailProvider(NotificationProvider): - # interfaces with the notification system def __init__(self, settings: SMTPSettings, users_repo: PaymentsUsersRepo): self._users_repo = users_repo self._settings = settings @@ -228,24 +224,24 @@ async def _create_message( self, user_id: UserID, payment: PaymentTransaction ) -> EmailMessage: - # retrieve info - user = await self._users_repo.get_email_info(user_id) - product = _ProductData( - product_name="osparc", - display_name="o²S²PARC", - vendor_display_inline="IT'IS Foundation. Zeughausstrasse 43, 8004 Zurich, Switzerland ", - support_email="support@osparc.io", - ) - - # TODO: product? via wallet_id? - # TODO: ger support email and display name + data = await self._users_repo.get_notification_data(user_id, payment.payment_id) - # compose email msg: EmailMessage = await _create_user_email( self._jinja_env, - user=user, - payment=payment, - product=product, + user=_UserData( + first_name=data.first_name, last_name=data.last_name, email=data.email + ), + payment=_PaymentData( + price_dollars=f"{payment.price_dollars:.2f}", + osparc_credits=f"{payment.osparc_credits:.2f}", + invoice_url=payment.invoice_url, + ), + product=_ProductData( + product_name=data.product_name, + display_name=data.display_name, + vendor_display_inline=f"{data.vendor.get('name', '')}. Zeughausstrasse 43, 8004 Zurich, Switzerland ", + support_email=data.support_email, + ), ) return msg From e62e4ed8c233ab8e8029e302c9ee75f10ae8b7cb Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 7 Feb 2024 18:56:17 +0100 Subject: [PATCH 21/31] minor --- .../tests/unit/test_services_notifier_email.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/services/payments/tests/unit/test_services_notifier_email.py b/services/payments/tests/unit/test_services_notifier_email.py index b458c4b1905..209f0afb649 100644 --- a/services/payments/tests/unit/test_services_notifier_email.py +++ b/services/payments/tests/unit/test_services_notifier_email.py @@ -20,6 +20,7 @@ _add_attachments, _create_email_session, _create_user_email, + _PaymentData, _ProductData, _UserData, ) @@ -88,7 +89,8 @@ async def test_send_email_workflow( vendor_display_inline="IT'IS Foundation. Zeughausstrasse 43, 8004 Zurich, Switzerland ", support_email="support@osparc.io", ) - payment = PaymentTransaction( + + transaction = PaymentTransaction( payment_id="pt_123234", price_dollars=faker.pydecimal(positive=True, right_digits=2, left_digits=4), wallet_id=12, @@ -101,6 +103,12 @@ async def test_send_email_workflow( invoice_url=faker.image_url(), ) + payment = _PaymentData( + price_dollars=f"{transaction.price_dollars:.2f}", + osparc_credits=f"{transaction.osparc_credits:.2f}", + invoice_url=transaction.invoice_url, + ) + msg = await _create_user_email(env, user, payment, product) attachment = tmp_path / "test-attachment.txt" From 03e77626c96f71dab8a6045c008ea6c8b0d84593 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 8 Feb 2024 15:23:47 +0100 Subject: [PATCH 22/31] product vendor --- .../models/products.py | 3 ++- .../pytest_simcore/helpers/rawdata_fakers.py | 27 ++++++++++--------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/products.py b/packages/postgres-database/src/simcore_postgres_database/models/products.py index 6c6d528ab99..2ccf43188e1 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/products.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/products.py @@ -31,12 +31,13 @@ class Vendor(TypedDict, total=False): E.g. company name, address, copyright, etc. """ - name: str + name: str # e.g. IT'IS Foundation copyright: str url: str license_url: str # Which are the license terms? (if applies) invitation_url: str # How to request a trial invitation? (if applies) has_landing_page: bool # Landing page enabled + address: str # e.g. Zeughausstrasse 43, 8004 Zurich, Switzerland class IssueTracker(TypedDict, total=True): diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py b/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py index d8479a2582d..8226cff01f3 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py @@ -14,6 +14,7 @@ import itertools import json import random +import re from collections.abc import Callable from datetime import datetime, timedelta, timezone from typing import Any, Final @@ -169,24 +170,26 @@ def random_product( - group_id: product group ID. SEE get_or_create_product_group to produce `group_id` - registration_email_template """ + from simcore_postgres_database.models.products import Vendor - fake_vendor = { - "name": fake.company(), - "copyright": fake.company_suffix(), - "url": fake.url(), - "license_url": fake.url(), - "invitation_url": fake.url(), - "has_landing_page": fake.boolean(), - } + suffix = fake.unique.word() data = { - "name": fake.unique.first_name(), - "display_name": fake.company(), - "short_name": fake.user_name()[:10], + "name": f"prd_{suffix}", + "display_name": suffix.capitalize(), + "short_name": re.sub(r"[aeiou]", "", suffix, flags=re.IGNORECASE), "host_regex": r"[a-zA-Z0-9]+\.com", "support_email": fake.email(), "twilio_messaging_sid": fake.random_element(elements=(None, fake.uuid4()[:34])), - "vendor": fake.random_element([None, fake_vendor]), + "vendor": Vendor( + name=fake.company(), + copyright=fake.company_suffix(), + url=fake.url(), + license_url=fake.url(), + invitation_url=fake.url(), + has_landing_page=fake.boolean(), + address=fake.address().replace("\n", ". "), + ), "registration_email_template": registration_email_template, "created": fake.date_time_this_decade(), "modified": fake.date_time_this_decade(), From b4c59de71c4dda0192d78f86fd313c2125312689 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 8 Feb 2024 15:24:04 +0100 Subject: [PATCH 23/31] minor --- .../src/simcore_service_payments/services/notifier_email.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 79591305e66..5adb10772bd 100644 --- a/services/payments/src/simcore_service_payments/services/notifier_email.py +++ b/services/payments/src/simcore_service_payments/services/notifier_email.py @@ -99,7 +99,7 @@ "base.html": _BASE_HTML, "notify_payments.html": _NOTIFY_PAYMENTS_HTML, "notify_payments.txt": _NOTIFY_PAYMENTS_TXT, - "notify_payments_subject.txt": _NOTIFY_PAYMENTS_SUBJECT, + "notify_payments-subject.txt": _NOTIFY_PAYMENTS_SUBJECT, } @@ -142,7 +142,7 @@ async def _create_user_email( addr_spec=user.email, ) - msg["Subject"] = env.get_template("notify_payments_subject.txt").render(data) + msg["Subject"] = env.get_template("notify_payments-subject.txt").render(data) # body data = { From 3ee1a8f96e7be2a66cdd8e821891775383258645 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 8 Feb 2024 15:24:31 +0100 Subject: [PATCH 24/31] test email provider --- services/payments/tests/conftest.py | 11 +- .../unit/test_services_notifier_email.py | 110 +++++++++++++----- .../tests/unit/test_services_payments.py | 2 +- 3 files changed, 92 insertions(+), 31 deletions(-) diff --git a/services/payments/tests/conftest.py b/services/payments/tests/conftest.py index 3169d1d31e0..c6bdf64e62d 100644 --- a/services/payments/tests/conftest.py +++ b/services/payments/tests/conftest.py @@ -6,6 +6,7 @@ import re from pathlib import Path +from typing import Any import pytest import simcore_service_payments @@ -16,6 +17,7 @@ from models_library.users import GroupID, UserID from models_library.wallets import WalletID from pydantic import EmailStr, parse_obj_as +from pytest_simcore.helpers.rawdata_fakers import random_product from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.utils_envs import setenvs_from_dict from servicelib.utils_secrets import generate_token_secret_key @@ -124,8 +126,13 @@ def app_environment( @pytest.fixture -def product_name(faker: Faker) -> ProductName: - return parse_obj_as(IDStr, f"product-{faker.word()}") +def product(faker: Faker) -> dict[str, Any]: + return random_product(fake=faker) + + +@pytest.fixture +def product_name(faker: Faker, product: dict[str, Any]) -> ProductName: + return parse_obj_as(IDStr, product["name"]) @pytest.fixture diff --git a/services/payments/tests/unit/test_services_notifier_email.py b/services/payments/tests/unit/test_services_notifier_email.py index 209f0afb649..57b0514d37a 100644 --- a/services/payments/tests/unit/test_services_notifier_email.py +++ b/services/payments/tests/unit/test_services_notifier_email.py @@ -4,19 +4,28 @@ # pylint: disable=too-many-arguments from pathlib import Path -from unittest.mock import AsyncMock +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock import arrow import pytest from faker import Faker from jinja2 import DictLoader, Environment, select_autoescape from models_library.api_schemas_webserver.wallets import PaymentTransaction +from models_library.products import ProductName +from models_library.users import UserID +from models_library.wallets import WalletID +from pydantic import EmailStr, parse_obj_as from pytest_mock import MockerFixture from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.utils_envs import setenvs_from_dict from settings_library.email import SMTPSettings +from simcore_postgres_database.models.products import Vendor +from simcore_service_payments.db.payment_users_repo import PaymentsUsersRepo from simcore_service_payments.services.notifier_email import ( _PRODUCT_NOTIFICATIONS_TEMPLATES, + EmailProvider, _add_attachments, _create_email_session, _create_user_email, @@ -43,20 +52,31 @@ def app_environment( @pytest.fixture(scope="session") def external_email(request: pytest.FixtureRequest) -> str | None: - return request.config.getoption("--external-email", default=None) + email_or_none = request.config.getoption("--external-email", default=None) + return parse_obj_as(EmailStr, email_or_none) if email_or_none else None @pytest.fixture -def user(faker: Faker, external_email: str | None): - email = faker.email() +def user_email(user_email: EmailStr, external_email: EmailStr | None) -> EmailStr: if external_email: print("📧 EXTERNAL using in test", f"{external_email=}") - email = external_email + return external_email + return user_email - return _UserData( - first_name=faker.first_name(), - last_name=faker.last_name(), - email=email, + +@pytest.fixture +def transaction(faker: Faker, wallet_id: WalletID) -> PaymentTransaction: + return PaymentTransaction( + payment_id=f"pt_{faker.pyint()}", + price_dollars=faker.pydecimal(positive=True, right_digits=2, left_digits=4), + wallet_id=wallet_id, + osparc_credits=faker.pydecimal(positive=True, right_digits=2, left_digits=4), + comment=f"fake payment fixture in {__name__}", + created_at=arrow.now().datetime, + completed_at=arrow.now().datetime, + completedStatus="SUCCESS", + state_message="ok", + invoice_url=faker.image_url(), ) @@ -64,9 +84,12 @@ async def test_send_email_workflow( app_environment: EnvVarsDict, tmp_path: Path, faker: Faker, - user: _UserData, + transaction: PaymentTransaction, external_email: str | None, mocker: MockerFixture, + user_email: EmailStr, + product_name: ProductName, + product: dict[str, Any], ): """ Example of usage with external email and envfile @@ -83,33 +106,28 @@ async def test_send_email_workflow( autoescape=select_autoescape(["html", "xml"]), ) - product = _ProductData( - product_name="osparc", - display_name="o²S²PARC", - vendor_display_inline="IT'IS Foundation. Zeughausstrasse 43, 8004 Zurich, Switzerland ", - support_email="support@osparc.io", + user_data = _UserData( + first_name=faker.first_name(), + last_name=faker.last_name(), + email=user_email, ) - transaction = PaymentTransaction( - payment_id="pt_123234", - price_dollars=faker.pydecimal(positive=True, right_digits=2, left_digits=4), - wallet_id=12, - osparc_credits=faker.pydecimal(positive=True, right_digits=2, left_digits=4), - comment="fake", - created_at=arrow.now().datetime, - completed_at=arrow.now().datetime, - completedStatus="SUCCESS", - state_message="ok", - invoice_url=faker.image_url(), + vendor: Vendor = product["vendor"] + + product_data = _ProductData( # type: ignore + product_name=product_name, + display_name=product["display_name"], + vendor_display_inline=f"{vendor.get('name','')}, {vendor.get('address','')}", + support_email=product["support_email"], ) - payment = _PaymentData( + payment_data = _PaymentData( price_dollars=f"{transaction.price_dollars:.2f}", osparc_credits=f"{transaction.osparc_credits:.2f}", invoice_url=transaction.invoice_url, ) - msg = await _create_user_email(env, user, payment, product) + msg = await _create_user_email(env, user_data, payment_data, product_data) attachment = tmp_path / "test-attachment.txt" attachment.write_text(faker.text()) @@ -122,3 +140,39 @@ async def test_send_email_workflow( assert isinstance(smtp, AsyncMock) assert smtp.login.called assert smtp.send_message.called + + +async def test_email_provider( + app_environment: EnvVarsDict, + mocker: MockerFixture, + user_id: UserID, + user_first_name: str, + user_last_name: str, + user_email: EmailStr, + product_name: ProductName, + product: dict[str, Any], + transaction: PaymentTransaction, +): + settings = SMTPSettings.create_from_envs() + + # mock access to db + users_repo = PaymentsUsersRepo(MagicMock()) + get_notification_data_mock = mocker.patch.object( + users_repo, + "get_notification_data", + return_value=SimpleNamespace( + payment_id=transaction.payment_id, + first_name=user_first_name, + last_name=user_last_name, + email=user_email, + product_name=product_name, + display_name=product["display_name"], + vendor=product["vendor"], + support_email=product["support_email"], + ), + ) + + provider = EmailProvider(settings, users_repo) + + await provider.notify_payment_completed(user_id=user_id, payment=transaction) + assert get_notification_data_mock.called diff --git a/services/payments/tests/unit/test_services_payments.py b/services/payments/tests/unit/test_services_payments.py index 5790b5f53fa..4a75d52c3cc 100644 --- a/services/payments/tests/unit/test_services_payments.py +++ b/services/payments/tests/unit/test_services_payments.py @@ -4,7 +4,7 @@ # pylint: disable=too-many-arguments -from typing import Awaitable, Callable +from collections.abc import Awaitable, Callable import pytest from fastapi import FastAPI From e424e01a281c7351b2068710e559ffa81403221c Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 8 Feb 2024 15:47:25 +0100 Subject: [PATCH 25/31] tuning tests --- .../pytest_simcore/helpers/rawdata_fakers.py | 3 +-- .../services/notifier_email.py | 16 +++++++------- services/payments/tests/conftest.py | 17 +++++++++------ .../tests/unit/test_db_payments_users_repo.py | 9 ++++++++ .../unit/test_services_notifier_email.py | 21 ++++++++++++++----- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py b/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py index 8226cff01f3..9b422f0fc52 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py @@ -29,7 +29,7 @@ from simcore_postgres_database.models.payments_transactions import ( PaymentTransactionState, ) -from simcore_postgres_database.models.products import products +from simcore_postgres_database.models.products import Vendor, products from simcore_postgres_database.models.projects import projects from simcore_postgres_database.models.users import users from simcore_postgres_database.webserver_models import GroupType, UserStatus @@ -170,7 +170,6 @@ def random_product( - group_id: product group ID. SEE get_or_create_product_group to produce `group_id` - registration_email_template """ - from simcore_postgres_database.models.products import Vendor suffix = fake.unique.word() 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 5adb10772bd..40955a0f200 100644 --- a/services/payments/src/simcore_service_payments/services/notifier_email.py +++ b/services/payments/src/simcore_service_payments/services/notifier_email.py @@ -131,6 +131,13 @@ async def _create_user_email( payment: _PaymentData, product: _ProductData, ) -> EmailMessage: + # data to interpolate template + data = { + "user": user, + "product": product, + "payment": payment, + } + msg = EmailMessage() msg["From"] = Address( @@ -141,16 +148,9 @@ async def _create_user_email( display_name=f"{user.first_name} {user.last_name}", addr_spec=user.email, ) - msg["Subject"] = env.get_template("notify_payments-subject.txt").render(data) - # body - data = { - "user": user, - "product": product, - "payment": payment, - } - + # Body text_template = env.get_template("notify_payments.txt") msg.set_content(text_template.render(data)) diff --git a/services/payments/tests/conftest.py b/services/payments/tests/conftest.py index c6bdf64e62d..c52cc09c519 100644 --- a/services/payments/tests/conftest.py +++ b/services/payments/tests/conftest.py @@ -120,14 +120,9 @@ def app_environment( ) -# -# Fakes -# - - @pytest.fixture def product(faker: Faker) -> dict[str, Any]: - return random_product(fake=faker) + return random_product(support_email="support@osparc.io", fake=faker) @pytest.fixture @@ -150,6 +145,16 @@ def user_email(faker: Faker) -> EmailStr: return parse_obj_as(EmailStr, faker.email()) +@pytest.fixture +def user_first_name(faker: Faker) -> str: + return faker.first_name() + + +@pytest.fixture +def user_last_name(faker: Faker) -> str: + return faker.last_name() + + @pytest.fixture def user_name(user_email: str) -> IDStr: return parse_obj_as(IDStr, user_email.split("@")[0]) diff --git a/services/payments/tests/unit/test_db_payments_users_repo.py b/services/payments/tests/unit/test_db_payments_users_repo.py index 82087105003..2b86dce4ad5 100644 --- a/services/payments/tests/unit/test_db_payments_users_repo.py +++ b/services/payments/tests/unit/test_db_payments_users_repo.py @@ -87,3 +87,12 @@ async def test_payments_user_repo( ): repo = PaymentsUsersRepo(get_engine(app)) assert await repo.get_primary_group_id(user_id) == user_primary_group_id + + +async def test_get_notification_data(app: FastAPI, user_id: UserID, payment_id): + repo = PaymentsUsersRepo(get_engine(app)) + + # create product + # create payment + + data = await repo.get_notification_data(user_id, payment_id) diff --git a/services/payments/tests/unit/test_services_notifier_email.py b/services/payments/tests/unit/test_services_notifier_email.py index 57b0514d37a..d7f67e630fe 100644 --- a/services/payments/tests/unit/test_services_notifier_email.py +++ b/services/payments/tests/unit/test_services_notifier_email.py @@ -80,16 +80,25 @@ def transaction(faker: Faker, wallet_id: WalletID) -> PaymentTransaction: ) +@pytest.fixture +def smtp_mock_or_none( + mocker: MockerFixture, external_email: EmailStr | None +) -> MagicMock | None: + if not external_email: + return mocker.patch("simcore_service_payments.services.notifier_email.SMTP") + return None + + async def test_send_email_workflow( app_environment: EnvVarsDict, tmp_path: Path, faker: Faker, transaction: PaymentTransaction, external_email: str | None, - mocker: MockerFixture, user_email: EmailStr, product_name: ProductName, product: dict[str, Any], + smtp_mock_or_none: MagicMock | None, ): """ Example of usage with external email and envfile @@ -97,9 +106,6 @@ async def test_send_email_workflow( > pytest --external-email=me@email.me --external-envfile=.myenv -k test_send_email_workflow --pdb tests/unit """ - if not external_email: - mocker.patch("simcore_service_payments.services.notifier_email.SMTP") - settings = SMTPSettings.create_from_envs() env = Environment( loader=DictLoader(_PRODUCT_NOTIFICATIONS_TEMPLATES), @@ -136,7 +142,8 @@ async def test_send_email_workflow( async with _create_email_session(settings) as smtp: await smtp.send_message(msg) - if not external_email: + if smtp_mock_or_none: + assert smtp_mock_or_none.called assert isinstance(smtp, AsyncMock) assert smtp.login.called assert smtp.send_message.called @@ -152,6 +159,7 @@ async def test_email_provider( product_name: ProductName, product: dict[str, Any], transaction: PaymentTransaction, + smtp_mock_or_none: MagicMock | None, ): settings = SMTPSettings.create_from_envs() @@ -176,3 +184,6 @@ async def test_email_provider( await provider.notify_payment_completed(user_id=user_id, payment=transaction) assert get_notification_data_mock.called + + if smtp_mock_or_none: + assert smtp_mock_or_none.called From c239a880dedab929a6ef32148676eb3067427671 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 8 Feb 2024 17:02:38 +0100 Subject: [PATCH 26/31] fixtures --- services/payments/tests/conftest.py | 64 +++++++++- .../tests/unit/test_db_payments_users_repo.py | 116 +++++++++++++----- .../test_services_auto_recharge_listener.py | 3 +- .../unit/test_services_notifier_email.py | 27 ++-- 4 files changed, 154 insertions(+), 56 deletions(-) diff --git a/services/payments/tests/conftest.py b/services/payments/tests/conftest.py index c52cc09c519..e26148ee873 100644 --- a/services/payments/tests/conftest.py +++ b/services/payments/tests/conftest.py @@ -5,6 +5,7 @@ # pylint: disable=unused-variable import re +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -17,10 +18,17 @@ from models_library.users import GroupID, UserID from models_library.wallets import WalletID from pydantic import EmailStr, parse_obj_as -from pytest_simcore.helpers.rawdata_fakers import random_product +from pytest_simcore.helpers.rawdata_fakers import ( + random_payment_transaction, + random_product, + random_user, +) from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.utils_envs import setenvs_from_dict from servicelib.utils_secrets import generate_token_secret_key +from simcore_postgres_database.models.payments_transactions import ( + PaymentTransactionState, +) pytest_plugins = [ "pytest_simcore.cli_runner", @@ -135,11 +143,6 @@ def user_id(faker: Faker) -> UserID: return parse_obj_as(UserID, faker.pyint()) -@pytest.fixture -def user_primary_group_id(faker: Faker) -> GroupID: - return parse_obj_as(GroupID, faker.pyint()) - - @pytest.fixture def user_email(faker: Faker) -> EmailStr: return parse_obj_as(EmailStr, faker.email()) @@ -160,6 +163,30 @@ def user_name(user_email: str) -> IDStr: return parse_obj_as(IDStr, user_email.split("@")[0]) +@pytest.fixture +def user( + faker: Faker, + user_id: UserID, + user_email: EmailStr, + user_first_name: str, + user_last_name: str, + user_name: IDStr, +) -> dict[str, Any]: + return random_user( + id=user_id, + email=user_email, + name=user_name, + first_name=user_first_name, + last_name=user_last_name, + fake=faker, + ) + + +@pytest.fixture +def user_primary_group_id(faker: Faker) -> GroupID: + return parse_obj_as(GroupID, faker.pyint()) + + @pytest.fixture def wallet_id(faker: Faker) -> WalletID: return parse_obj_as(WalletID, faker.pyint()) @@ -168,3 +195,28 @@ def wallet_id(faker: Faker) -> WalletID: @pytest.fixture def wallet_name(faker: Faker) -> IDStr: return parse_obj_as(IDStr, f"wallet-{faker.word()}") + + +@pytest.fixture +def successful_transaction( + faker: Faker, + wallet_id: WalletID, + user_email: EmailStr, + user_id: UserID, + product_name: ProductName, +) -> dict[str, Any]: + initiated_at = datetime.now(tz=timezone.utc) + return random_payment_transaction( + payment_id=f"pt_{faker.pyint()}", + price_dollars=faker.pydecimal(positive=True, right_digits=2, left_digits=4), + state=PaymentTransactionState.SUCCESS, + initiated_at=initiated_at, + completed_at=initiated_at + timedelta(seconds=10), + osparc_credits=faker.pydecimal(positive=True, right_digits=2, left_digits=4), + product_name=product_name, + user_id=user_id, + user_email=user_email, + wallet_id=wallet_id, + comment=f"fake fixture in {__name__}.successful_transaction", + invoice_url=faker.image_url(), + ) diff --git a/services/payments/tests/unit/test_db_payments_users_repo.py b/services/payments/tests/unit/test_db_payments_users_repo.py index 2b86dce4ad5..18d5b1bc585 100644 --- a/services/payments/tests/unit/test_db_payments_users_repo.py +++ b/services/payments/tests/unit/test_db_payments_users_repo.py @@ -5,15 +5,17 @@ # pylint: disable=unused-variable +from collections.abc import AsyncIterator +from typing import Any + import pytest import sqlalchemy as sa from fastapi import FastAPI -from models_library.basic_types import IDStr from models_library.users import GroupID, UserID -from pydantic import EmailStr -from pytest_simcore.helpers.rawdata_fakers import random_user from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.utils_envs import setenvs_from_dict +from simcore_postgres_database.models.payments_transactions import payments_transactions +from simcore_postgres_database.models.products import products from simcore_postgres_database.models.users import users from simcore_service_payments.db.payment_users_repo import PaymentsUsersRepo from simcore_service_payments.services.postgres import get_engine @@ -47,39 +49,89 @@ def app_environment( ) +async def _insert_and_get_row( + conn, table: sa.Table, values: dict[str, Any], pk_col: sa.Column, pk_value: Any +): + result = await conn.execute(table.insert().values(**values).returning(pk_col)) + row = result.first() + assert row[pk_col] == pk_value + + result = await conn.execute(sa.select(table).where(pk_col == pk_value)) + row = result.first() + assert row + return row + + +async def _delete_row(conn, table, pk_col: sa.Column, pk_value: Any): + await conn.execute(table.delete().where(pk_col == pk_value)) + + @pytest.fixture async def user( app: FastAPI, - user_email: EmailStr, - user_name: IDStr, + user: dict[str, Any], user_id: UserID, -): +) -> AsyncIterator[dict[str, Any]]: + """ + injects a user in db + """ + assert user_id == user["id"] + _pk_args = users.c.id, user["id"] + + # NOTE: creation of primary group and setting `groupid`` is automatically triggered after creation of user by postgres async with get_engine(app).begin() as conn: - result = await conn.execute( - users.insert() - .values( - **random_user(email=user_email, name=user_name), - id=user_id, - ) - .returning(users.c.id) - ) - row = result.first() - assert row - # NOTE: groupid is triggered afterwards - result = await conn.execute(sa.select(users).where(users.c.id == row.id)) - row = result.first() + row = await _insert_and_get_row(conn, users, user, *_pk_args) - assert row - yield row + yield dict(row.items()) + + async with get_engine(app).begin() as conn: + await _delete_row(conn, users, *_pk_args) + + +@pytest.fixture +def user_primary_group_id(user: dict[str, Any]) -> GroupID: + # Overrides `user_primary_group_id` since new user triggers an automatic creation of a primary group + return user["primary_gid"] + + +@pytest.fixture +async def product( + app: FastAPI, product: dict[str, Any] +) -> AsyncIterator[dict[str, Any]]: + """ + injects product in db + """ + # NOTE: this fixture ignores products' group-id but it is fine for this test context + assert product["group_id"] is None + _pk_args = products.c.name, product["name"] + + async with get_engine(app).begin() as conn: + row = await _insert_and_get_row(conn, products, product, *_pk_args) + + yield dict(row.items()) async with get_engine(app).begin() as conn: - await conn.execute(users.delete().where(users.c.id == row.id)) + await _delete_row(conn, products, *_pk_args) @pytest.fixture -def user_primary_group_id(user) -> GroupID: - # Overrides `user_primary_group_id` - return user.primary_gid +async def successful_transaction( + app: FastAPI, successful_transaction: dict[str, Any] +) -> AsyncIterator[dict[str, Any]]: + """ + injects transaction in db + """ + _pk_args = payments_transactions.c.payment_id, successful_transaction["paymet_id"] + + async with get_engine(app).begin() as conn: + row = await _insert_and_get_row( + conn, payments_transactions, successful_transaction, *_pk_args + ) + + yield dict(row.items()) + + async with get_engine(app).begin() as conn: + await _delete_row(conn, payments_transactions, *_pk_args) async def test_payments_user_repo( @@ -89,10 +141,14 @@ async def test_payments_user_repo( assert await repo.get_primary_group_id(user_id) == user_primary_group_id -async def test_get_notification_data(app: FastAPI, user_id: UserID, payment_id): +async def test_get_notification_data( + app: FastAPI, + user: dict[str, Any], + product: dict[str, Any], + successful_transaction: dict[str, Any], +): repo = PaymentsUsersRepo(get_engine(app)) - # create product - # create payment - - data = await repo.get_notification_data(user_id, payment_id) + data = await repo.get_notification_data( + user_id=user["id"], payment_id=successful_transaction["payment_id"] + ) diff --git a/services/payments/tests/unit/test_services_auto_recharge_listener.py b/services/payments/tests/unit/test_services_auto_recharge_listener.py index bc54d8297e4..656b5f18aeb 100644 --- a/services/payments/tests/unit/test_services_auto_recharge_listener.py +++ b/services/payments/tests/unit/test_services_auto_recharge_listener.py @@ -4,10 +4,9 @@ # pylint: disable=unused-argument # pylint: disable=unused-variable -from collections.abc import Callable +from collections.abc import Awaitable, Callable, Iterator from datetime import datetime, timedelta, timezone from decimal import Decimal -from typing import Awaitable, Iterator from unittest import mock import pytest diff --git a/services/payments/tests/unit/test_services_notifier_email.py b/services/payments/tests/unit/test_services_notifier_email.py index d7f67e630fe..37f1b046dcf 100644 --- a/services/payments/tests/unit/test_services_notifier_email.py +++ b/services/payments/tests/unit/test_services_notifier_email.py @@ -8,14 +8,12 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock -import arrow import pytest from faker import Faker from jinja2 import DictLoader, Environment, select_autoescape from models_library.api_schemas_webserver.wallets import PaymentTransaction from models_library.products import ProductName from models_library.users import UserID -from models_library.wallets import WalletID from pydantic import EmailStr, parse_obj_as from pytest_mock import MockerFixture from pytest_simcore.helpers.typing_env import EnvVarsDict @@ -64,22 +62,6 @@ def user_email(user_email: EmailStr, external_email: EmailStr | None) -> EmailSt return user_email -@pytest.fixture -def transaction(faker: Faker, wallet_id: WalletID) -> PaymentTransaction: - return PaymentTransaction( - payment_id=f"pt_{faker.pyint()}", - price_dollars=faker.pydecimal(positive=True, right_digits=2, left_digits=4), - wallet_id=wallet_id, - osparc_credits=faker.pydecimal(positive=True, right_digits=2, left_digits=4), - comment=f"fake payment fixture in {__name__}", - created_at=arrow.now().datetime, - completed_at=arrow.now().datetime, - completedStatus="SUCCESS", - state_message="ok", - invoice_url=faker.image_url(), - ) - - @pytest.fixture def smtp_mock_or_none( mocker: MockerFixture, external_email: EmailStr | None @@ -89,6 +71,15 @@ def smtp_mock_or_none( return None +@pytest.fixture +def transaction( + faker: Faker, successful_transaction: dict[str, Any] +) -> PaymentTransaction: + return PaymentTransaction.parse_obj( + {k: successful_transaction[k] for k in PaymentTransaction.__fields__} + ) + + async def test_send_email_workflow( app_environment: EnvVarsDict, tmp_path: Path, From 7271381952e0d4ab95562249a6ee3dc8320667a5 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 8 Feb 2024 17:44:06 +0100 Subject: [PATCH 27/31] tests pass --- .../db/payment_users_repo.py | 2 +- .../services/notifier_email.py | 10 ++--- .../tests/unit/test_db_payments_users_repo.py | 38 +++++++++++-------- 3 files changed, 28 insertions(+), 22 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 0281b97eb22..6a2c53c7be0 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 @@ -38,7 +38,7 @@ async def get_notification_data(self, user_id: UserID, payment_id: PaymentID): users.c.first_name, users.c.last_name, users.c.email, - products.c.product_name, + products.c.name.label("product_name"), products.c.display_name, products.c.vendor, products.c.support_email, 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 40955a0f200..b67abe5cb79 100644 --- a/services/payments/src/simcore_service_payments/services/notifier_email.py +++ b/services/payments/src/simcore_service_payments/services/notifier_email.py @@ -216,10 +216,6 @@ def __init__(self, settings: SMTPSettings, users_repo: PaymentsUsersRepo): autoescape=select_autoescape(["html", "xml"]), ) - async def on_app_startup(self): - # TODO: get templates from db upon start and not everytime - raise NotImplementedError - async def _create_message( self, user_id: UserID, payment: PaymentTransaction ) -> EmailMessage: @@ -229,7 +225,9 @@ async def _create_message( msg: EmailMessage = await _create_user_email( self._jinja_env, user=_UserData( - first_name=data.first_name, last_name=data.last_name, email=data.email + first_name=data.first_name, + last_name=data.last_name, + email=data.email, ), payment=_PaymentData( price_dollars=f"{payment.price_dollars:.2f}", @@ -239,7 +237,7 @@ async def _create_message( product=_ProductData( product_name=data.product_name, display_name=data.display_name, - vendor_display_inline=f"{data.vendor.get('name', '')}. Zeughausstrasse 43, 8004 Zurich, Switzerland ", + vendor_display_inline=f"{data.vendor.get('name', '')}. {data.vendor.get('address', '')}", support_email=data.support_email, ), ) diff --git a/services/payments/tests/unit/test_db_payments_users_repo.py b/services/payments/tests/unit/test_db_payments_users_repo.py index 18d5b1bc585..cfd34cd707e 100644 --- a/services/payments/tests/unit/test_db_payments_users_repo.py +++ b/services/payments/tests/unit/test_db_payments_users_repo.py @@ -57,9 +57,7 @@ async def _insert_and_get_row( assert row[pk_col] == pk_value result = await conn.execute(sa.select(table).where(pk_col == pk_value)) - row = result.first() - assert row - return row + return result.first() async def _delete_row(conn, table, pk_col: sa.Column, pk_value: Any): @@ -76,16 +74,16 @@ async def user( injects a user in db """ assert user_id == user["id"] - _pk_args = users.c.id, user["id"] + pk_args = users.c.id, user["id"] # NOTE: creation of primary group and setting `groupid`` is automatically triggered after creation of user by postgres async with get_engine(app).begin() as conn: - row = await _insert_and_get_row(conn, users, user, *_pk_args) + row = await _insert_and_get_row(conn, users, user, *pk_args) - yield dict(row.items()) + yield dict(row) async with get_engine(app).begin() as conn: - await _delete_row(conn, users, *_pk_args) + await _delete_row(conn, users, *pk_args) @pytest.fixture @@ -103,15 +101,15 @@ async def product( """ # NOTE: this fixture ignores products' group-id but it is fine for this test context assert product["group_id"] is None - _pk_args = products.c.name, product["name"] + pk_args = products.c.name, product["name"] async with get_engine(app).begin() as conn: - row = await _insert_and_get_row(conn, products, product, *_pk_args) + row = await _insert_and_get_row(conn, products, product, *pk_args) - yield dict(row.items()) + yield dict(row) async with get_engine(app).begin() as conn: - await _delete_row(conn, products, *_pk_args) + await _delete_row(conn, products, *pk_args) @pytest.fixture @@ -121,17 +119,17 @@ async def successful_transaction( """ injects transaction in db """ - _pk_args = payments_transactions.c.payment_id, successful_transaction["paymet_id"] + pk_args = payments_transactions.c.payment_id, successful_transaction["payment_id"] async with get_engine(app).begin() as conn: row = await _insert_and_get_row( - conn, payments_transactions, successful_transaction, *_pk_args + conn, payments_transactions, successful_transaction, *pk_args ) - yield dict(row.items()) + yield dict(row) async with get_engine(app).begin() as conn: - await _delete_row(conn, payments_transactions, *_pk_args) + await _delete_row(conn, payments_transactions, *pk_args) async def test_payments_user_repo( @@ -149,6 +147,16 @@ async def test_get_notification_data( ): repo = PaymentsUsersRepo(get_engine(app)) + # check once data = await repo.get_notification_data( user_id=user["id"], payment_id=successful_transaction["payment_id"] ) + + assert data.payment_id == successful_transaction["payment_id"] + assert data.first_name == user["first_name"] + assert data.last_name == user["last_name"] + assert data.email == user["email"] + assert data.product_name == product["name"] + assert data.display_name == product["display_name"] + assert data.vendor == product["vendor"] + assert data.support_email == product["support_email"] From 530ac58d2ba02444847725695f7bc0d780a5e0fb Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 8 Feb 2024 18:42:19 +0100 Subject: [PATCH 28/31] fixes test and doc --- services/payments/tests/conftest.py | 52 ++++++++++++++++++- services/payments/tests/unit/conftest.py | 42 --------------- .../payments/tests/unit/test_core_settings.py | 5 ++ .../unit/test_services_notifier_email.py | 9 +++- 4 files changed, 63 insertions(+), 45 deletions(-) diff --git a/services/payments/tests/conftest.py b/services/payments/tests/conftest.py index e26148ee873..43d83670288 100644 --- a/services/payments/tests/conftest.py +++ b/services/payments/tests/conftest.py @@ -24,7 +24,7 @@ random_user, ) from pytest_simcore.helpers.typing_env import EnvVarsDict -from pytest_simcore.helpers.utils_envs import setenvs_from_dict +from pytest_simcore.helpers.utils_envs import load_dotenv, setenvs_from_dict from servicelib.utils_secrets import generate_token_secret_key from simcore_postgres_database.models.payments_transactions import ( PaymentTransactionState, @@ -75,6 +75,56 @@ def fake_password(faker: Faker) -> str: return faker.password(length=10) +def pytest_addoption(parser: pytest.Parser): + group = parser.getgroup( + "external_environment", + description="Replaces mocked services with real ones by passing actual environs and connecting directly to external services", + ) + group.addoption( + "--external-envfile", + action="store", + type=Path, + default=None, + help="Path to an env file. Consider passing a link to repo configs, i.e. `ln -s /path/to/osparc-ops-config/repo.config`", + ) + group.addoption( + "--external-email", + action="store", + type=str, + default=None, + help="An email for test_services_notifier_email", + ) + + +@pytest.fixture(scope="session") +def external_environment(request: pytest.FixtureRequest) -> EnvVarsDict: + """ + If a file under test folder prefixed with `.env-secret` is present, + then this fixture captures it. + + This technique allows reusing the same tests to check against + external development/production servers + """ + envs = {} + if envfile := request.config.getoption("--external-envfile"): + assert isinstance(envfile, Path) + print("🚨 EXTERNAL: external envs detected. Loading", envfile, "...") + envs = load_dotenv(envfile) + assert "PAYMENTS_GATEWAY_API_SECRET" in envs + assert "PAYMENTS_GATEWAY_URL" in envs + + return envs + + +@pytest.fixture +def env_devel_dict( + env_devel_dict: EnvVarsDict, external_environment: EnvVarsDict +) -> EnvVarsDict: + if external_environment: + return external_environment + return env_devel_dict + + @pytest.fixture def docker_compose_service_payments_env_vars( services_docker_compose_file: Path, diff --git a/services/payments/tests/unit/conftest.py b/services/payments/tests/unit/conftest.py index 51b4f2bd09a..4617fa58844 100644 --- a/services/payments/tests/unit/conftest.py +++ b/services/payments/tests/unit/conftest.py @@ -26,7 +26,6 @@ from pytest_mock import MockerFixture from pytest_simcore.helpers.rawdata_fakers import random_payment_method_view from pytest_simcore.helpers.typing_env import EnvVarsDict -from pytest_simcore.helpers.utils_envs import load_dotenv from respx import MockRouter from servicelib.rabbitmq import RabbitMQRPCClient from simcore_postgres_database.models.payments_transactions import payments_transactions @@ -374,47 +373,6 @@ def _pay(request: httpx.Request, pm_id: PaymentMethodID): _payment_methods.clear() -def pytest_addoption(parser: pytest.Parser): - group = parser.getgroup( - "external_environment", - description="Replaces mocked services with real ones by passing actual environs and connecting directly to external services", - ) - group.addoption( - "--external-envfile", - action="store", - type=Path, - default=None, - help="Path to an env file. Consider passing a link to repo configs, i.e. `ln -s /path/to/osparc-ops-config/repo.config`", - ) - group.addoption( - "--external-email", - action="store", - type=str, - default=None, - help="An email for test_services_notifier_email", - ) - - -@pytest.fixture(scope="session") -def external_environment(request: pytest.FixtureRequest) -> EnvVarsDict: - """ - If a file under test folder prefixed with `.env-secret` is present, - then this fixture captures it. - - This technique allows reusing the same tests to check against - external development/production servers - """ - envs = {} - if envfile := request.config.getoption("--external-envfile"): - assert isinstance(envfile, Path) - print("🚨 EXTERNAL: external envs detected. Loading", envfile, "...") - envs = load_dotenv(envfile) - assert "PAYMENTS_GATEWAY_API_SECRET" in envs - assert "PAYMENTS_GATEWAY_URL" in envs - - return envs - - @pytest.fixture def mock_payments_gateway_service_or_none( mock_payments_gateway_service_api_base: MockRouter, diff --git a/services/payments/tests/unit/test_core_settings.py b/services/payments/tests/unit/test_core_settings.py index 16974202780..7fdf2551ad1 100644 --- a/services/payments/tests/unit/test_core_settings.py +++ b/services/payments/tests/unit/test_core_settings.py @@ -9,6 +9,11 @@ def test_valid_web_application_settings(app_environment: EnvVarsDict): + """ + We validate actual envfiles (e.g. repo.config files) by passing them via the CLI + + > pytest --external-envfile=.myenv --pdb tests/unit/test_core_settings.py + """ settings = ApplicationSettings() # type: ignore assert settings diff --git a/services/payments/tests/unit/test_services_notifier_email.py b/services/payments/tests/unit/test_services_notifier_email.py index 37f1b046dcf..401e9761538 100644 --- a/services/payments/tests/unit/test_services_notifier_email.py +++ b/services/payments/tests/unit/test_services_notifier_email.py @@ -75,8 +75,13 @@ def smtp_mock_or_none( def transaction( faker: Faker, successful_transaction: dict[str, Any] ) -> PaymentTransaction: - return PaymentTransaction.parse_obj( - {k: successful_transaction[k] for k in PaymentTransaction.__fields__} + kwargs = { + k: successful_transaction[k] + for k in PaymentTransaction.__fields__ + if k in successful_transaction + } + return PaymentTransaction( + created_at=successful_transaction["initiated_at"], **kwargs ) From f43594c53ad72a5603f343ff3d0649dfdb4ee80b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 8 Feb 2024 19:01:04 +0100 Subject: [PATCH 29/31] tests settings --- services/payments/Makefile | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/services/payments/Makefile b/services/payments/Makefile index 0af63b77c0c..f7b7f577538 100644 --- a/services/payments/Makefile +++ b/services/payments/Makefile @@ -28,3 +28,14 @@ test-dev-unit-external: ## runs test-dev against external service defined in $(e test-ci-unit-external: ## runs test-ci against external service defined in $(external) envfile # Running tests using external environ '$(external)' $(MAKE) test-ci-unit pytest-parameters="--external-envfile=$(external) -m can_run_against_external" + + +test-repo-config: ## runs validation against `repo.config` files. e.g. `make SEARCH_ROOT=/path/to/ospar-config/deployments` + @if [ -z "$(SEARCH_ROOT)" ]; then \ + echo "Error: SEARCH_ROOT is not set. Please set SEARCH_ROOT to the directory with repo.config files"; \ + exit 1; \ + fi + @for file in $$(find $(SEARCH_ROOT) -type f -name 'repo.config'); do \ + echo "Validating settings for $$file"; \ + pytest --external-envfile="$$file" --pdb tests/unit/test_core_settings.py; \ + done From 9e23e75727df51ec606523afe8d58b7fa0f26a89 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 8 Feb 2024 19:05:57 +0100 Subject: [PATCH 30/31] minor --- services/payments/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/payments/Makefile b/services/payments/Makefile index f7b7f577538..cf361c3c10e 100644 --- a/services/payments/Makefile +++ b/services/payments/Makefile @@ -30,7 +30,7 @@ test-ci-unit-external: ## runs test-ci against external service defined in $(ext $(MAKE) test-ci-unit pytest-parameters="--external-envfile=$(external) -m can_run_against_external" -test-repo-config: ## runs validation against `repo.config` files. e.g. `make SEARCH_ROOT=/path/to/ospar-config/deployments` +test-repo-config: ## runs validation against `repo.config` files. e.g. `make test-repo-config SEARCH_ROOT=/path/to/ospar-config/deployments` @if [ -z "$(SEARCH_ROOT)" ]; then \ echo "Error: SEARCH_ROOT is not set. Please set SEARCH_ROOT to the directory with repo.config files"; \ exit 1; \ From 770a6fd3d5aa492c9d710b2de53f1443c9749c7d Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 8 Feb 2024 21:31:44 +0100 Subject: [PATCH 31/31] fix --- .../src/pytest_simcore/helpers/rawdata_fakers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py b/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py index 9b422f0fc52..b45a47b3001 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py @@ -14,7 +14,6 @@ import itertools import json import random -import re from collections.abc import Callable from datetime import datetime, timedelta, timezone from typing import Any, Final @@ -171,12 +170,13 @@ def random_product( - registration_email_template """ - suffix = fake.unique.word() + name = overrides.get("name") + suffix = fake.unique.word() if name is None else name data = { "name": f"prd_{suffix}", "display_name": suffix.capitalize(), - "short_name": re.sub(r"[aeiou]", "", suffix, flags=re.IGNORECASE), + "short_name": suffix[:4], "host_regex": r"[a-zA-Z0-9]+\.com", "support_email": fake.email(), "twilio_messaging_sid": fake.random_element(elements=(None, fake.uuid4()[:34])),