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(