From c80cedd5dabf2db34420162d8e786accc65ecf41 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 9 Feb 2024 17:11:23 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Notify=20payment=20via=20email=20?= =?UTF-8?q?=E2=9A=A0=EF=B8=8F=20(#5310)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env-devel | 4 +- .../models/products.py | 3 +- .../pytest_simcore/helpers/rawdata_fakers.py | 28 +- .../src/settings_library/email.py | 4 + services/docker-compose.yml | 54 ++-- services/payments/Makefile | 11 + services/payments/requirements/_base.in | 4 +- services/payments/requirements/_base.txt | 26 +- .../api/rest/_acknowledgements.py | 6 +- .../simcore_service_payments/core/settings.py | 6 + .../db/payment_users_repo.py | 59 +++- .../services/notifier.py | 80 +++--- .../services/notifier_abc.py | 28 ++ .../services/notifier_email.py | 264 ++++++++++++++++++ .../services/notifier_ws.py | 61 ++++ .../services/payments.py | 4 +- .../services/payments_methods.py | 4 +- services/payments/tests/conftest.py | 145 ++++++++-- services/payments/tests/unit/conftest.py | 35 --- .../payments/tests/unit/test_core_settings.py | 5 + .../tests/unit/test_db_payments_users_repo.py | 123 ++++++-- .../test_services_auto_recharge_listener.py | 3 +- .../tests/unit/test_services_notifier.py | 5 +- .../unit/test_services_notifier_email.py | 185 ++++++++++++ .../tests/unit/test_services_payments.py | 2 +- .../simcore_service_webserver/email/_core.py | 15 +- 26 files changed, 982 insertions(+), 182 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_email.py create mode 100644 services/payments/src/simcore_service_payments/services/notifier_ws.py create mode 100644 services/payments/tests/unit/test_services_notifier_email.py 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/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..b45a47b3001 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/rawdata_fakers.py @@ -28,7 +28,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,23 +170,25 @@ def random_product( - registration_email_template """ - 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(), - } + name = overrides.get("name") + suffix = fake.unique.word() if name is None else name 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": 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])), - "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(), 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/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} diff --git a/services/payments/Makefile b/services/payments/Makefile index 0af63b77c0c..cf361c3c10e 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 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; \ + 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 diff --git a/services/payments/requirements/_base.in b/services/payments/requirements/_base.in index 5ef19fb42d5..da3813cc2bb 100644 --- a/services/payments/requirements/_base.in +++ b/services/payments/requirements/_base.in @@ -14,12 +14,14 @@ --requirement ../../../packages/service-library/requirements/_fastapi.in +aiosmtplib # notifier cryptography fastapi httpx +Jinja2 # notifier packaging python-jose python-multipart -python-socketio +python-socketio # notifier typer[all] uvicorn[standard] 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 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/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/db/payment_users_repo.py b/services/payments/src/simcore_service_payments/db/payment_users_repo.py index 2ebb951f130..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 @@ -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,13 +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) + + 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.name.label("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) diff --git a/services/payments/src/simcore_service_payments/services/notifier.py b/services/payments/src/simcore_service_payments/services/notifier.py index 96f47d96061..43a0b6bcbd4 100644 --- a/services/payments/src/simcore_service_payments/services/notifier.py +++ b/services/payments/src/simcore_service_payments/services/notifier.py @@ -1,35 +1,30 @@ 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, ) 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 +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,15 @@ async def notify_payment_completed( payment: PaymentTransaction, ): if payment.completed_at is None: - msg = "Incomplete payment" + msg = "Cannot notify 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), + 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( @@ -57,29 +48,48 @@ 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 logged_gather( + *( + provider.notify_payment_method_acked( + user_id=user_id, payment_method=payment_method + ) + for provider in self.providers + ), + reraise=False, ) 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 new file mode 100644 index 00000000000..b67abe5cb79 --- /dev/null +++ b/services/payments/src/simcore_service_payments/services/notifier_email.py @@ -0,0 +1,264 @@ +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 + +from aiosmtplib import SMTP +from attr import dataclass +from jinja2 import DictLoader, Environment, select_autoescape +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 EmailProtocol, SMTPSettings + +from ..db.payment_users_repo import PaymentsUsersRepo +from .notifier_abc import NotificationProvider + +_logger = logging.getLogger(__name__) + + +_BASE_HTML: Final[ + str +] = """ + + + + + +{% block title %}{% endblock %} + + + + {% 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 }} +""" + + +_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, +} + + +@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 + invoice_url: str + + +async def _create_user_email( + env: Environment, + user: _UserData, + payment: _PaymentData, + product: _ProductData, +) -> EmailMessage: + # data to interpolate template + data = { + "user": user, + "product": product, + "payment": payment, + } + + 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, + ) + msg["Subject"] = env.get_template("notify_payments-subject.txt").render(data) + + # Body + 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 + + +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[SMTP]: + async with 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 # nosec + assert settings.SMTP_PASSWORD # nosec + await smtp.login( + settings.SMTP_USERNAME, + settings.SMTP_PASSWORD.get_secret_value(), + ) + + yield cast(SMTP, smtp) + + +class EmailProvider(NotificationProvider): + 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 _create_message( + self, user_id: UserID, payment: PaymentTransaction + ) -> EmailMessage: + + data = await self._users_repo.get_notification_data(user_id, payment.payment_id) + + msg: EmailMessage = await _create_user_email( + self._jinja_env, + 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', '')}. {data.vendor.get('address', '')}", + support_email=data.support_email, + ), + ) + + return msg + + async def notify_payment_completed( + self, + user_id: UserID, + payment: PaymentTransaction, + ): + msg = await self._create_message(user_id, payment) + + 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, + ): + assert user_id # nosec + assert payment_method # nosec + _logger.debug("No email sent when payment method is acked") 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/conftest.py b/services/payments/tests/conftest.py index 19f7c5a682c..43d83670288 100644 --- a/services/payments/tests/conftest.py +++ b/services/payments/tests/conftest.py @@ -5,7 +5,9 @@ # pylint: disable=unused-variable import re +from datetime import datetime, timedelta, timezone from pathlib import Path +from typing import Any import pytest import simcore_service_payments @@ -16,9 +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_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 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, +) pytest_plugins = [ "pytest_simcore.cli_runner", @@ -65,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, @@ -76,8 +136,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 +151,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 @@ -119,14 +178,14 @@ def app_environment( ) -# -# Fakes -# +@pytest.fixture +def product(faker: Faker) -> dict[str, Any]: + return random_product(support_email="support@osparc.io", fake=faker) @pytest.fixture -def product_name(faker: Faker) -> ProductName: - return parse_obj_as(IDStr, f"product-{faker.word()}") +def product_name(faker: Faker, product: dict[str, Any]) -> ProductName: + return parse_obj_as(IDStr, product["name"]) @pytest.fixture @@ -135,13 +194,18 @@ def user_id(faker: Faker) -> UserID: @pytest.fixture -def user_primary_group_id(faker: Faker) -> GroupID: - return parse_obj_as(GroupID, faker.pyint()) +def user_email(faker: Faker) -> EmailStr: + return parse_obj_as(EmailStr, faker.email()) @pytest.fixture -def user_email(faker: Faker) -> EmailStr: - return parse_obj_as(EmailStr, faker.email()) +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 @@ -149,6 +213,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()) @@ -157,3 +245,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/conftest.py b/services/payments/tests/unit/conftest.py index 23c67c3a64c..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,40 +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`", - ) - - -@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_db_payments_users_repo.py b/services/payments/tests/unit/test_db_payments_users_repo.py index 82087105003..cfd34cd707e 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,87 @@ 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)) + return result.first() + + +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) async with get_engine(app).begin() as conn: - await conn.execute(users.delete().where(users.c.id == row.id)) + await _delete_row(conn, users, *pk_args) @pytest.fixture -def user_primary_group_id(user) -> GroupID: - # Overrides `user_primary_group_id` - return user.primary_gid +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) + + async with get_engine(app).begin() as conn: + await _delete_row(conn, products, *pk_args) + + +@pytest.fixture +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["payment_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) + + async with get_engine(app).begin() as conn: + await _delete_row(conn, payments_transactions, *pk_args) async def test_payments_user_repo( @@ -87,3 +137,26 @@ 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: dict[str, Any], + product: dict[str, Any], + successful_transaction: dict[str, Any], +): + 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"] 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.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) ) 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..401e9761538 --- /dev/null +++ b/services/payments/tests/unit/test_services_notifier_email.py @@ -0,0 +1,185 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +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 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, + _PaymentData, + _ProductData, + _UserData, +) + + +@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(scope="session") +def external_email(request: pytest.FixtureRequest) -> str | 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_email(user_email: EmailStr, external_email: EmailStr | None) -> EmailStr: + if external_email: + print("📧 EXTERNAL using in test", f"{external_email=}") + return external_email + return user_email + + +@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 + + +@pytest.fixture +def transaction( + faker: Faker, successful_transaction: dict[str, Any] +) -> PaymentTransaction: + kwargs = { + k: successful_transaction[k] + for k in PaymentTransaction.__fields__ + if k in successful_transaction + } + return PaymentTransaction( + created_at=successful_transaction["initiated_at"], **kwargs + ) + + +async def test_send_email_workflow( + app_environment: EnvVarsDict, + tmp_path: Path, + faker: Faker, + transaction: PaymentTransaction, + external_email: str | None, + user_email: EmailStr, + product_name: ProductName, + product: dict[str, Any], + smtp_mock_or_none: MagicMock | None, +): + """ + 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 + """ + + settings = SMTPSettings.create_from_envs() + env = Environment( + loader=DictLoader(_PRODUCT_NOTIFICATIONS_TEMPLATES), + autoescape=select_autoescape(["html", "xml"]), + ) + + user_data = _UserData( + first_name=faker.first_name(), + last_name=faker.last_name(), + email=user_email, + ) + + 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_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_data, payment_data, product_data) + + attachment = tmp_path / "test-attachment.txt" + attachment.write_text(faker.text()) + _add_attachments(msg, [attachment]) + + async with _create_email_session(settings) as smtp: + await smtp.send_message(msg) + + if smtp_mock_or_none: + assert smtp_mock_or_none.called + 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, + smtp_mock_or_none: MagicMock | None, +): + 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 + + if smtp_mock_or_none: + assert smtp_mock_or_none.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 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(