From 2a5534cf64f6b4262fbc10e411c186fab3332146 Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Fri, 25 Jul 2025 16:03:39 -0300 Subject: [PATCH 01/14] SIENTIAPDE-1172 feat: enhance orchestrator activities with new email and Postgres integrations - Added Email and Postgres classes to the Activities class for improved functionality. - Introduced new methods in MongoDB and SlotManager for loading and managing data. - Updated requirements.txt to include jinja2. - Added new formatting activity for log reports in Formatters class. - Enhanced test coverage for MongoDB and SlotManager activities. --- email.html | 247 +++++++++++++++ notification_generator.ipynb | 287 ++++++++++++++++++ orchestrator/activities/activities.py | 26 +- orchestrator/activities/email.py | 149 +++++++++ orchestrator/activities/formatters.py | 45 +++ orchestrator/activities/mongo_db.py | 89 +++++- orchestrator/activities/slot_manager.py | 70 +++++ orchestrator/utils/email_builder.py | 75 +++++ .../utils/templates/email_template.html | 24 ++ .../utils/templates/general_template.html | 24 ++ orchestrator/workflows/alerts.py | 22 ++ orchestrator/workflows/orchestrator.py | 9 + orchestrator/workflows/reports.py | 19 ++ .../subworkflows/load_notification_package.py | 93 ++++++ .../subworkflows/process_notifications.py | 87 ++++++ requirements.txt | 1 + .../orchestrator/activities/test_mongo_db.py | 131 ++++++++ .../activities/test_slot_manager.py | 153 ++++++++++ .../test_load_notification_package.py | 115 +++++++ 19 files changed, 1660 insertions(+), 6 deletions(-) create mode 100644 email.html create mode 100644 notification_generator.ipynb create mode 100644 orchestrator/activities/email.py create mode 100644 orchestrator/utils/email_builder.py create mode 100644 orchestrator/utils/templates/email_template.html create mode 100644 orchestrator/utils/templates/general_template.html create mode 100644 orchestrator/workflows/alerts.py create mode 100644 orchestrator/workflows/reports.py create mode 100644 orchestrator/workflows/subworkflows/load_notification_package.py create mode 100644 orchestrator/workflows/subworkflows/process_notifications.py create mode 100644 tests/orchestrator/workflows/subworkflows/test_load_notification_package.py diff --git a/email.html b/email.html new file mode 100644 index 0000000..7f53648 --- /dev/null +++ b/email.html @@ -0,0 +1,247 @@ + + + + + + SIENTIA™ Report + + + +

SIENTIA™ Alerts

+ +

Errors detected:

+ +

Model: ut aliqua ut ipsum

+ + + + + + + + + + + + + + + + + + + +
Notification IDBlockTimestampMessage
TAG_node5:tag_87_LISTENNING_STOPPEDdolor_elit_aliqua2025-07-25 16:50:48.925421+00:00sit elit ipsum labore incididunt magna do et sit incididunt
+ +

Model: elit dolore

+ + + + + + + + + + + + + + + + + + + +
Notification IDBlockTimestampMessage
OPC_LISTENNING_BACK__server_10magna_magna_et2025-07-25 16:50:48.925478+00:00aliqua eiusmod ut eiusmod ut et ipsum
+ +

Model: lorem lorem ipsum amet

+ + + + + + + + + + + + + + + + + + + +
Notification IDBlockTimestampMessage
TAG_node8:tag_11_LISTENNING_STOPPEDdo_sit2025-07-25 16:50:48.925506+00:00labore do adipiscing adipiscing incididunt eiusmod
+ +

Warnings detected:

+ +

Model: amet eiusmod dolor ipsum

+ + + + + + + + + + + + + + + + + + + +
Notification IDBlockTimestampMessage
REPORT_PARTITION_MANAGERadipiscing_amet_ut_et2025-07-25 16:50:48.925368+00:00eiusmod et amet sit lorem incididunt
+ +

Model: sit et labore

+ + + + + + + + + + + + + + + + + + + +
Notification IDBlockTimestampMessage
SIT_CONSECTETUR_EIUSMODadipiscing_elit_ut2025-07-25 16:50:48.925392+00:00magna do eiusmod et labore elit
+ +

Model: consectetur labore do consectetur

+ + + + + + + + + + + + + + + + + + + +
Notification IDBlockTimestampMessage
SED_SIT_DOLOR_CONSECTETURdolor_dolore_ut2025-07-25 16:50:48.925435+00:00magna sed incididunt dolor tempor
+ +

Model: et sed ut et

+ + + + + + + + + + + + + + + + + + + +
Notification IDBlockTimestampMessage
UT_UT_LABOREsit_do2025-07-25 16:50:48.925492+00:00do sed sed elit do eiusmod
+ +

Infos detected:

+ +

Model: eiusmod eiusmod

+ + + + + + + + + + + + + + + + + + + +
Notification IDBlockTimestampMessage
OPC_CONNECTION_RETRY__server_2labore_sit_do2025-07-25 16:50:48.925407+00:00magna et ipsum amet dolore adipiscing adipiscing do
+ +

Model: adipiscing consectetur

+ + + + + + + + + + + + + + + + + + + +
Notification IDBlockTimestampMessage
REPORT_PARTITION_MANAGERincididunt_aliqua_ipsum_ut2025-07-25 16:50:48.925447+00:00labore magna adipiscing tempor elit et et magna elit lorem
+ +

Model: et amet

+ + + + + + + + + + + + + + + + + + + +
Notification IDBlockTimestampMessage
OPC_LISTENNING_BACK__server_2sed_elit_ut_do2025-07-25 16:50:48.925462+00:00dolor et adipiscing adipiscing dolore ipsum ipsum aliqua et elit
+ + + + + \ No newline at end of file diff --git a/notification_generator.ipynb b/notification_generator.ipynb new file mode 100644 index 0000000..197e89b --- /dev/null +++ b/notification_generator.ipynb @@ -0,0 +1,287 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 2, + "id": "1786cbf0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Generated 10 notifications\n" + ] + } + ], + "source": [ + "from unittest.mock import MagicMock\n", + "from orchestrator.activities.mongo_db import MongoDB\n", + "from orchestrator.utils.connectors_config import build_mongodb_config\n", + "import random\n", + "from datetime import datetime, timezone\n", + "import json\n", + "\n", + "word_list = [\n", + " 'lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur',\n", + " 'adipiscing', 'elit', 'sed', 'do', 'eiusmod', 'tempor',\n", + " 'incididunt', 'ut', 'labore', 'et', 'dolore', 'magna', 'aliqua'\n", + " ]\n", + "\n", + "config = build_mongodb_config()\n", + "\n", + "mongo_db = MongoDB(\n", + " connection_string=config['connection_string'],\n", + " database_name=config['database_name'],\n", + " ttl_index_seconds=config['ttl_index_seconds'],\n", + " logger=MagicMock(),\n", + " notification_handler=MagicMock()\n", + ")\n", + "\n", + "AMOUT = 10\n", + "\n", + "notification_ids = [\n", + " \"TAG_node:_LISTENNING_STOPPED\",\n", + " \"OPC___\",\n", + " \"REPORT_PARTITION_MANAGER\",\n", + " \"\"\n", + "]\n", + "\n", + "notifications = []\n", + "\n", + "for _ in range(AMOUT):\n", + " # Choose random notification ID and fill in placeholders\n", + " notification_id = random.choice(notification_ids)\n", + " \n", + " if \"\" in notification_id:\n", + " notification_id = notification_id.replace(\"\", str(random.randint(1,100)))\n", + " if \"\" in notification_id:\n", + " notification_id = notification_id.replace(\"\", f\"tag_{random.randint(1,100)}\")\n", + " if \"\" in notification_id:\n", + " events = [\"LISTENNING_STOPPED\", \"CONNECTION_RETRY\", \"LISTENNING_BACK\", \"SUBSCRIPTION\", \"QUEUE_RETRIEVE\"]\n", + " notification_id = notification_id.replace(\"\", random.choice(events))\n", + " if \"\" in notification_id:\n", + " notification_id = notification_id.replace(\"\", f\"server_{random.randint(1,10)}\")\n", + " if \"\" in notification_id:\n", + " notification_id = notification_id.replace(\"\", \"_\".join(random.choices(word_list, k=random.randint(3,5))).upper())\n", + "\n", + " notification = {\n", + " \"project\": \"sientia-laborious\",\n", + " \"pipeline\": \"_\".join(random.choices(word_list, k=random.randint(2,4))), \n", + " \"trigger\": \"_\".join(random.choices(word_list, k=random.randint(3,6))),\n", + " \"model_name\": \" \".join(random.choices(word_list, k=random.randint(2,4))),\n", + " \"model_id\": str(random.randint(1,100)),\n", + " \"block\": \"_\".join(random.choices(word_list, k=random.randint(2,4))),\n", + " \"level\": random.choice([\"INFO\", \"WARNING\", \"ERROR\"]),\n", + " \"message\": \" \".join(random.choices(word_list, k=random.randint(5,10))),\n", + " \"attachment_content\": \" \".join(random.choices(word_list, k=random.randint(20,30))),\n", + " \"notification_id\": notification_id,\n", + " \"timestamp\": datetime.now(timezone.utc)\n", + " }\n", + " \n", + " notifications.append(notification)\n", + "\n", + "print(f\"Generated {len(notifications)} notifications\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "c1467b72", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'WARNING': {'section_name': 'Warnings detected:', 'models': {'amet eiusmod dolor ipsum': {'model_name': 'amet eiusmod dolor ipsum', 'events': [{'project': 'sientia-laborious', 'pipeline': 'ut_eiusmod_do', 'trigger': 'aliqua_magna_labore_magna_do_ut', 'model_name': 'amet eiusmod dolor ipsum', 'model_id': '26', 'block': 'adipiscing_amet_ut_et', 'level': 'WARNING', 'message': 'eiusmod et amet sit lorem incididunt', 'attachment_content': 'sed et ipsum aliqua et consectetur incididunt aliqua incididunt aliqua eiusmod adipiscing incididunt sed et ut ipsum aliqua elit ut', 'notification_id': 'REPORT_PARTITION_MANAGER', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925368, tzinfo=datetime.timezone.utc)}]}, 'sit et labore': {'model_name': 'sit et labore', 'events': [{'project': 'sientia-laborious', 'pipeline': 'tempor_eiusmod_amet_amet', 'trigger': 'labore_elit_adipiscing_adipiscing', 'model_name': 'sit et labore', 'model_id': '8', 'block': 'adipiscing_elit_ut', 'level': 'WARNING', 'message': 'magna do eiusmod et labore elit', 'attachment_content': 'incididunt incididunt do adipiscing consectetur adipiscing ipsum incididunt et sed sed magna dolore ut ipsum amet dolor sed tempor lorem incididunt do labore elit aliqua magna eiusmod dolor', 'notification_id': 'SIT_CONSECTETUR_EIUSMOD', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925392, tzinfo=datetime.timezone.utc)}]}, 'consectetur labore do consectetur': {'model_name': 'consectetur labore do consectetur', 'events': [{'project': 'sientia-laborious', 'pipeline': 'et_consectetur_ut', 'trigger': 'labore_aliqua_tempor_amet', 'model_name': 'consectetur labore do consectetur', 'model_id': '50', 'block': 'dolor_dolore_ut', 'level': 'WARNING', 'message': 'magna sed incididunt dolor tempor', 'attachment_content': 'lorem labore tempor amet adipiscing lorem dolor aliqua do et incididunt adipiscing labore aliqua dolor elit aliqua eiusmod sed aliqua aliqua consectetur incididunt sed ipsum eiusmod labore do', 'notification_id': 'SED_SIT_DOLOR_CONSECTETUR', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925435, tzinfo=datetime.timezone.utc)}]}, 'et sed ut et': {'model_name': 'et sed ut et', 'events': [{'project': 'sientia-laborious', 'pipeline': 'eiusmod_consectetur_eiusmod_dolor', 'trigger': 'aliqua_sit_eiusmod', 'model_name': 'et sed ut et', 'model_id': '60', 'block': 'sit_do', 'level': 'WARNING', 'message': 'do sed sed elit do eiusmod', 'attachment_content': 'sit labore magna labore lorem eiusmod aliqua magna et eiusmod magna labore et amet adipiscing consectetur sit ut dolore tempor tempor amet ut', 'notification_id': 'UT_UT_LABORE', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925492, tzinfo=datetime.timezone.utc)}]}}}, 'INFO': {'section_name': 'Infos detected:', 'models': {'eiusmod eiusmod': {'model_name': 'eiusmod eiusmod', 'events': [{'project': 'sientia-laborious', 'pipeline': 'do_et_et_dolor', 'trigger': 'adipiscing_dolor_dolore_adipiscing_tempor', 'model_name': 'eiusmod eiusmod', 'model_id': '70', 'block': 'labore_sit_do', 'level': 'INFO', 'message': 'magna et ipsum amet dolore adipiscing adipiscing do', 'attachment_content': 'aliqua amet lorem amet elit tempor amet aliqua do aliqua ut dolore sed eiusmod consectetur tempor dolor sit magna eiusmod consectetur ipsum', 'notification_id': 'OPC_CONNECTION_RETRY__server_2', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925407, tzinfo=datetime.timezone.utc)}]}, 'adipiscing consectetur': {'model_name': 'adipiscing consectetur', 'events': [{'project': 'sientia-laborious', 'pipeline': 'sit_do_aliqua', 'trigger': 'aliqua_amet_magna_ipsum_lorem', 'model_name': 'adipiscing consectetur', 'model_id': '48', 'block': 'incididunt_aliqua_ipsum_ut', 'level': 'INFO', 'message': 'labore magna adipiscing tempor elit et et magna elit lorem', 'attachment_content': 'magna labore dolor ipsum dolor lorem sed magna eiusmod ipsum do aliqua magna labore et elit consectetur et adipiscing lorem incididunt lorem', 'notification_id': 'REPORT_PARTITION_MANAGER', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925447, tzinfo=datetime.timezone.utc)}]}, 'et amet': {'model_name': 'et amet', 'events': [{'project': 'sientia-laborious', 'pipeline': 'consectetur_labore_magna', 'trigger': 'eiusmod_incididunt_consectetur_tempor', 'model_name': 'et amet', 'model_id': '31', 'block': 'sed_elit_ut_do', 'level': 'INFO', 'message': 'dolor et adipiscing adipiscing dolore ipsum ipsum aliqua et elit', 'attachment_content': 'tempor dolore ipsum ut ut et dolor eiusmod do magna magna magna et tempor labore labore dolor labore eiusmod do dolor incididunt lorem dolore ipsum ipsum aliqua sed incididunt', 'notification_id': 'OPC_LISTENNING_BACK__server_2', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925462, tzinfo=datetime.timezone.utc)}]}}}, 'ERROR': {'section_name': 'Errors detected:', 'models': {'ut aliqua ut ipsum': {'model_name': 'ut aliqua ut ipsum', 'events': [{'project': 'sientia-laborious', 'pipeline': 'sit_amet', 'trigger': 'aliqua_sit_consectetur_lorem_consectetur', 'model_name': 'ut aliqua ut ipsum', 'model_id': '80', 'block': 'dolor_elit_aliqua', 'level': 'ERROR', 'message': 'sit elit ipsum labore incididunt magna do et sit incididunt', 'attachment_content': 'amet ut et ut sed ipsum aliqua elit incididunt sed dolore dolore ipsum sed elit sit adipiscing incididunt elit labore do magna et consectetur aliqua', 'notification_id': 'TAG_node5:tag_87_LISTENNING_STOPPED', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925421, tzinfo=datetime.timezone.utc)}]}, 'elit dolore': {'model_name': 'elit dolore', 'events': [{'project': 'sientia-laborious', 'pipeline': 'sed_et_tempor_labore', 'trigger': 'aliqua_tempor_sit_lorem_amet_et', 'model_name': 'elit dolore', 'model_id': '20', 'block': 'magna_magna_et', 'level': 'ERROR', 'message': 'aliqua eiusmod ut eiusmod ut et ipsum', 'attachment_content': 'labore do dolore dolore adipiscing amet tempor eiusmod labore elit consectetur incididunt dolor et elit eiusmod ut ut aliqua incididunt', 'notification_id': 'OPC_LISTENNING_BACK__server_10', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925478, tzinfo=datetime.timezone.utc)}]}, 'lorem lorem ipsum amet': {'model_name': 'lorem lorem ipsum amet', 'events': [{'project': 'sientia-laborious', 'pipeline': 'amet_eiusmod_eiusmod', 'trigger': 'magna_aliqua_dolore_incididunt_do_do', 'model_name': 'lorem lorem ipsum amet', 'model_id': '47', 'block': 'do_sit', 'level': 'ERROR', 'message': 'labore do adipiscing adipiscing incididunt eiusmod', 'attachment_content': 'sit amet dolor magna et sit adipiscing sed consectetur labore sit ut adipiscing magna amet dolor lorem dolore eiusmod sed dolor dolor sit consectetur sit aliqua dolore tempor', 'notification_id': 'TAG_node8:tag_11_LISTENNING_STOPPED', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925506, tzinfo=datetime.timezone.utc)}]}}}}\n" + ] + } + ], + "source": [ + "from orchestrator.utils.email_builder import EmailBuilder\n", + "from unittest.mock import MagicMock\n", + "\n", + "\n", + "email_builder = EmailBuilder(logger=MagicMock())\n", + "\n", + "html = email_builder.build_email(notifications, \"Alerts\")\n", + "\n", + "with open('email.html', 'w') as file:\n", + " file.write(html)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "ffd7be77", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[{'project': 'sientia-laborious',\n", + " 'pipeline': 'lorem_sed_ut',\n", + " 'trigger': 'dolore_elit_lorem',\n", + " 'model_name': 'lorem tempor',\n", + " 'model_id': '71',\n", + " 'block': 'aliqua_aliqua_et',\n", + " 'level': 'ERROR',\n", + " 'message': 'tempor eiusmod dolor eiusmod sed amet labore et magna',\n", + " 'attachment_content': 'sed amet incididunt et amet lorem lorem ut adipiscing magna incididunt sit aliqua ut dolore dolore ipsum aliqua amet labore dolor amet et elit dolore dolor consectetur',\n", + " 'notification_id': 'OPC_LISTENNING_BACK__server_1',\n", + " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147390, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'et_do_eiusmod_adipiscing',\n", + " 'trigger': 'consectetur_magna_elit',\n", + " 'model_name': 'elit incididunt elit',\n", + " 'model_id': '85',\n", + " 'block': 'amet_elit_adipiscing',\n", + " 'level': 'WARNING',\n", + " 'message': 'do labore consectetur lorem lorem sit do aliqua magna magna',\n", + " 'attachment_content': 'aliqua tempor aliqua adipiscing sit elit ut magna sit ipsum magna tempor do incididunt sit magna sit consectetur amet elit lorem ipsum incididunt ipsum',\n", + " 'notification_id': 'ADIPISCING_DO_DO_DOLOR',\n", + " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147411, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'magna_tempor',\n", + " 'trigger': 'et_elit_labore_ut_eiusmod',\n", + " 'model_name': 'sit ipsum et eiusmod',\n", + " 'model_id': '14',\n", + " 'block': 'incididunt_do',\n", + " 'level': 'INFO',\n", + " 'message': 'sit elit eiusmod consectetur magna labore',\n", + " 'attachment_content': 'amet sit et do dolore eiusmod incididunt sed dolor dolor ipsum tempor lorem dolore sed amet ipsum ut lorem aliqua',\n", + " 'notification_id': 'IPSUM_ADIPISCING_IPSUM_MAGNA_LABORE',\n", + " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147426, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'adipiscing_adipiscing_adipiscing',\n", + " 'trigger': 'elit_dolor_sit_et',\n", + " 'model_name': 'sit adipiscing tempor sed',\n", + " 'model_id': '46',\n", + " 'block': 'tempor_tempor_sit',\n", + " 'level': 'WARNING',\n", + " 'message': 'tempor adipiscing tempor labore dolore tempor elit',\n", + " 'attachment_content': 'elit sit dolore consectetur ut ipsum ipsum aliqua incididunt elit eiusmod labore sed eiusmod eiusmod aliqua eiusmod eiusmod labore magna incididunt consectetur ipsum et aliqua sit adipiscing labore do',\n", + " 'notification_id': 'TAG_node57:tag_97_LISTENNING_STOPPED',\n", + " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147440, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'eiusmod_amet_aliqua',\n", + " 'trigger': 'ipsum_lorem_et',\n", + " 'model_name': 'consectetur adipiscing',\n", + " 'model_id': '72',\n", + " 'block': 'tempor_et_adipiscing_sed',\n", + " 'level': 'ERROR',\n", + " 'message': 'lorem ipsum do amet elit eiusmod elit lorem',\n", + " 'attachment_content': 'ipsum ut dolor eiusmod dolore elit consectetur ipsum dolor do sed dolor aliqua sit consectetur aliqua sit sit consectetur et incididunt adipiscing lorem do adipiscing',\n", + " 'notification_id': 'REPORT_PARTITION_MANAGER',\n", + " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147453, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'eiusmod_et_incididunt',\n", + " 'trigger': 'dolore_do_lorem_aliqua',\n", + " 'model_name': 'aliqua sed tempor ipsum',\n", + " 'model_id': '2',\n", + " 'block': 'et_elit_amet',\n", + " 'level': 'WARNING',\n", + " 'message': 'consectetur aliqua dolore amet eiusmod aliqua',\n", + " 'attachment_content': 'sit amet amet aliqua incididunt aliqua amet ipsum magna do adipiscing aliqua tempor magna dolor dolor labore et lorem consectetur dolore do ut adipiscing dolore',\n", + " 'notification_id': 'AMET_ELIT_ELIT_MAGNA_ALIQUA',\n", + " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147482, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'amet_dolore',\n", + " 'trigger': 'labore_magna_labore',\n", + " 'model_name': 'dolor ut incididunt adipiscing',\n", + " 'model_id': '3',\n", + " 'block': 'dolor_sit',\n", + " 'level': 'WARNING',\n", + " 'message': 'ut ipsum sed consectetur eiusmod',\n", + " 'attachment_content': 'aliqua dolor magna magna sed et magna sit aliqua labore aliqua sed ipsum aliqua amet dolore ipsum aliqua consectetur adipiscing eiusmod',\n", + " 'notification_id': 'REPORT_PARTITION_MANAGER',\n", + " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147494, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'dolor_ipsum',\n", + " 'trigger': 'do_sed_et_dolore_elit',\n", + " 'model_name': 'adipiscing aliqua labore magna',\n", + " 'model_id': '74',\n", + " 'block': 'incididunt_dolore',\n", + " 'level': 'INFO',\n", + " 'message': 'amet do amet sed sit',\n", + " 'attachment_content': 'et sed labore adipiscing labore dolore amet tempor incididunt aliqua dolor labore tempor adipiscing incididunt consectetur magna tempor tempor aliqua ut sit magna dolore ipsum',\n", + " 'notification_id': 'REPORT_PARTITION_MANAGER',\n", + " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147510, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'magna_sed_ut_sit',\n", + " 'trigger': 'ut_dolore_consectetur_labore_incididunt',\n", + " 'model_name': 'aliqua et consectetur',\n", + " 'model_id': '15',\n", + " 'block': 'ipsum_et',\n", + " 'level': 'ERROR',\n", + " 'message': 'ut dolore ipsum magna tempor sed magna labore amet magna',\n", + " 'attachment_content': 'ipsum adipiscing et amet elit dolor ut aliqua consectetur ut do elit amet tempor incididunt eiusmod eiusmod dolore ipsum sit dolore adipiscing aliqua elit adipiscing labore sed eiusmod ut',\n", + " 'notification_id': 'LABORE_INCIDIDUNT_ADIPISCING_DO_LABORE',\n", + " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147525, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'lorem_adipiscing_lorem_incididunt',\n", + " 'trigger': 'adipiscing_magna_dolor_consectetur_elit',\n", + " 'model_name': 'dolor sit lorem',\n", + " 'model_id': '51',\n", + " 'block': 'dolor_ipsum',\n", + " 'level': 'INFO',\n", + " 'message': 'labore aliqua dolor eiusmod incididunt adipiscing tempor',\n", + " 'attachment_content': 'lorem magna dolore consectetur eiusmod sit dolor eiusmod ut do dolor labore eiusmod et dolore sit tempor consectetur ipsum adipiscing eiusmod dolore consectetur labore sit do',\n", + " 'notification_id': 'CONSECTETUR_MAGNA_AMET_LOREM_TEMPOR',\n", + " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147539, tzinfo=datetime.timezone.utc)}]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "notifications" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "55aed216", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "InsertManyResult([ObjectId('68839c5572e2953521d60ada'), ObjectId('68839c5572e2953521d60adb'), ObjectId('68839c5572e2953521d60adc'), ObjectId('68839c5572e2953521d60add'), ObjectId('68839c5572e2953521d60ade'), ObjectId('68839c5572e2953521d60adf'), ObjectId('68839c5572e2953521d60ae0'), ObjectId('68839c5572e2953521d60ae1'), ObjectId('68839c5572e2953521d60ae2'), ObjectId('68839c5572e2953521d60ae3')], acknowledged=True)" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mongo_db.database['notification_queue'].insert_many(notifications)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/orchestrator/activities/activities.py b/orchestrator/activities/activities.py index 6c769cc..10c1812 100644 --- a/orchestrator/activities/activities.py +++ b/orchestrator/activities/activities.py @@ -1,6 +1,7 @@ from temporalio import activity, workflow from temporalio.client import Client +from orchestrator.activities.email import Email from orchestrator.activities.mongo_db import MongoDB with workflow.unsafe.imports_passed_through(): @@ -10,17 +11,21 @@ with workflow.unsafe.imports_passed_through(): from orchestrator.activities.formatters import Formatters from typing import Any from logging import Logger + from sientia_do.temporal.activities.postgres import Postgres from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler class Activities( # Couchbase, - TemporalManager, SlotManager, Formatters, MongoDB): + TemporalManager, SlotManager, Formatters, MongoDB, Email, + Postgres): def __init__(self, temporal_config: dict[str, Any], # couchbase_config: dict[str, Any], redis_config: dict[str, Any], mongodb_config: dict[str, Any], + email_config: dict[str, Any], + postgres_config: dict[str, Any], logger: Logger, notification_handler: NotificationHandler): @@ -59,5 +64,24 @@ class Activities( # Couchbase, logger=logger, notification_handler=notification_handler) + Email.__init__(self, + sender_email=email_config['sender_email'], + sender_password=email_config['sender_password'], + smpt_server=email_config['smpt_server'], + port=email_config['port'], + logger=logger, + notification_handler=notification_handler) + + Postgres.__init__(self, + host=postgres_config['host'], + port=postgres_config['port'], + user=postgres_config['username'], + password=postgres_config['password'], + dbname=postgres_config['database_name'], + min_connections=postgres_config['min_connections'], + max_connections=postgres_config['max_connections'], + logger=logger, + notification_handler=notification_handler) + def shutdown(self): MongoDB.shutdown(self) diff --git a/orchestrator/activities/email.py b/orchestrator/activities/email.py new file mode 100644 index 0000000..3355ebb --- /dev/null +++ b/orchestrator/activities/email.py @@ -0,0 +1,149 @@ +from email import encoders +from email.mime.base import MIMEBase +import traceback +from temporalio import workflow, activity + +with workflow.unsafe.imports_passed_through(): + import smtplib + from typing import Any + from sientia_do.temporal.activities.base import BaseActivity + from sientia_do.temporal.utils.logger import Logger + from sientia_do.notifications.handlers import NotificationHandler + from orchestrator.utils.email_builder import EmailBuilder + from email.mime.multipart import MIMEMultipart + from email.mime.text import MIMEText + + +class Email(BaseActivity): + def __init__(self, sender_email: str, sender_password: str, + smpt_server: str, port: int, + logger: Logger, notification_handler: NotificationHandler): + + self.email_builder = EmailBuilder(logger=logger) + + self.sender_email = sender_email + self.sender_password = sender_password + self.port = port + self.logger = logger + + if self.sender_password: + self.server = smtplib.SMTP_SSL(smpt_server, port) + self.server.login(self.sender_email, self.sender_password) + else: + self.server = smtplib.SMTP(smpt_server, port) + + BaseActivity.__init__(self, + logger=logger, + notification_handler=notification_handler) + + @activity.defn(name="build_email_html") + async def build_email_html(self, input_data: dict[str, Any]) -> str: + """ + Builds the email html for each receiver group. + input_data: + - receiver_groups (dict): The receiver groups. + - mail_type (str): The mail type. + """ + metadata = input_data['metadata'] + receiver_groups = input_data['receiver_groups'] + mail_type = input_data['mail_type'] + + self.info(f"Building email html for {mail_type} mail type.", + metadata=metadata) + + for group_name, group_config in receiver_groups.items(): + + html = self.email_builder.build_email( + group_config['notifications'], mail_type) + + group_config['html'] = html + + self.info(f"Email html built for {mail_type} mail type.", + metadata=metadata) + + return receiver_groups + + def handle_attachments(self, attachments: list[dict], msg: MIMEMultipart) -> MIMEMultipart: + """ + Attaches a list of attachments to an email message. + Args: + attachments (List[Dict]): A list of dictionaries where each dictionary contains + the keys 'attachment_id', 'trigger', and 'timestamp' representing the attachment details. + msg (MIMEMultipart): The email message object to which the attachments will be added. + Returns: + MIMEMultipart: The email message object with the attachments added. + Raises: + Exception: If an attachment cannot be added, an error is logged. + """ + + for attachment in attachments: + att_name = attachment['filename'] + try: + # Create the attachment as a MIMEBase object + part = MIMEBase('application', 'octet-stream') + part.set_payload( + attachment['attachment_content'].encode('utf-8')) + encoders.encode_base64(part) + part.add_header( + 'Content-Disposition', + f'attachment; filename="{att_name}"' + ) + msg.attach(part) + except Exception as e: + self.logger.error( + f"Failed to attach content of {att_name}: {e}") + + return msg + + @activity.defn(name="send_email") + async def send_email(self, input_data: dict[str, Any]) -> dict[str, Any]: + """ + Sends an email to the receivers of each group. + input_data: + - receiver_groups (dict): The receiver groups. + - mail_type (str): The mail type. + """ + metadata = input_data['metadata'] + receiver_groups = input_data['receiver_groups'] + mail_type = input_data['mail_type'] + + self.info(f"Sending email for {mail_type} mail type.", + metadata=metadata) + + for group_name, group_config in receiver_groups.items(): + + receivers = ", ".join(group_config['members']) + + self.info(f"Sending email to {group_name}: {receivers}", + metadata=metadata) + + msg = MIMEMultipart() + msg.attach(MIMEText(group_config['html'], 'html')) + msg['From'] = self.sender_email + msg['To'] = receivers + msg['Subject'] = f"SIENTIA™ {mail_type}" + + msg = self.handle_attachments( + [notification['attachment_content'] + for notification in group_config['notifications'] + if notification['attachment_content']], + msg) + + try: + self.server.sendmail( + self.sender_email, receivers, msg.as_string()) + except Exception as e: + self.error(f"Failed to send email to {group_name}: {e}", + metadata=metadata) + traceback.print_exc() + group_config['status'] = 'failed' + else: + group_config['status'] = 'sent' + + self.info(f"Email sent to {group_name}.", + metadata=metadata) + + self.info(f"Email sent for {mail_type} mail type.", + metadata=metadata) + + return receiver_groups diff --git a/orchestrator/activities/formatters.py b/orchestrator/activities/formatters.py index 5078b12..9557b31 100644 --- a/orchestrator/activities/formatters.py +++ b/orchestrator/activities/formatters.py @@ -1,4 +1,5 @@ +from pandas import DataFrame from temporalio import activity, workflow from orchestrator.utils.orchestrator_functions import minimal_retrain @@ -488,3 +489,47 @@ class Formatters(BaseActivity): notification_id="REPORT_ORCHESTRATION_DELETED_SLOTS", attachment=deleted_slots ) + + @activity.defn(name="format_log_report") + async def format_log_report(self, input_data: dict[str, Any]) -> dict[str, Any]: + """ + Formats the receiver_groups status to a dataframe to be stored in the database. + input_data: + - receiver_groups (dict): The receiver groups. + """ + metadata = input_data["metadata"] + mail_type = input_data["mail_type"] + + self.info("Formatting log report...", metadata=metadata) + + receiver_groups = input_data['receiver_groups'] + + data = {} + + for group_name, group_config in receiver_groups.items(): + + notification_id = group_config['notification_id'] + trigger = group_config['trigger'] + + key = f"{notification_id}:{trigger}" + + if key not in data: + data[key] = { + 'status': group_config['status'], + 'timestamp': group_config['timestamp'], + 'groups': [group_name], + 'message': group_config['message'], + 'level': group_config['level'], + 'notification_id': notification_id, + 'block': group_config['block'], + 'schedule': trigger, + 'pipeline': group_config['pipeline'], + 'project': group_config['project'], + 'model_name': group_config['model_name'], + 'model_id': group_config['model_id'], + 'mail_type': mail_type + } + else: + data[key]['groups'].append(group_name) + + return DataFrame(list(data.values())).to_dict() diff --git a/orchestrator/activities/mongo_db.py b/orchestrator/activities/mongo_db.py index 515a9b4..105264e 100644 --- a/orchestrator/activities/mongo_db.py +++ b/orchestrator/activities/mongo_db.py @@ -1,3 +1,4 @@ +from pandas import DataFrame from temporalio import workflow, activity @@ -80,6 +81,15 @@ class MongoDB(BaseActivity): """ self.shutdown() + def find(self, collection_name: str, filters: dict[str, Any]) -> list[dict[str, Any]]: + collection = self.database[collection_name] + + documents = list(collection.find(filters, {"_id": 0})) + + documents = clear_mongo_id(documents) + + return documents + @activity.defn(name="find_documents_in_mongodb",) async def find_documents_in_mongodb(self, input_data: dict[str, Any]) -> list[dict[str, Any]]: """ @@ -106,11 +116,7 @@ class MongoDB(BaseActivity): f"Loading documents from collection '{collection_name}' with filters: {filters}", metadata=metadata) try: - collection = self.database[collection_name] - - documents = list(collection.find(filters, {"_id": 0})) - - documents = clear_mongo_id(documents) + documents = self.find(collection_name, filters) self.info( f"Loaded {len(documents)} documents from collection '{collection_name}'", metadata=metadata) @@ -352,3 +358,76 @@ class MongoDB(BaseActivity): ) self.error(trace, metadata=metadata) raise e + + @activity.defn(name="load_latest_data") + async def load_latest_data(self, input_data: dict[str, Any]) -> list[dict[str, Any]]: + """ + Loads the latest data from MongoDB. + input_data: + - metadata (dict): The metadata of the workflow. + - collection_name (str): The name of the collection to load data from. + - last_data_timestamp (str): The timestamp of the last data to load. + - base_data_filter (dict): The base data filter to apply to the query. + returns: + - data (list[dict]): The data loaded from MongoDB. + """ + metadata = input_data['metadata'] + collection_name = input_data['collection_name'] + last_data_timestamp = input_data['last_data_timestamp'] + base_data_filter = input_data['base_data_filter'] + + self.debug( + f"Loading data from MongoDB: {input_data}", + metadata=metadata + ) + + try: + + if last_data_timestamp is None: + data_filter = base_data_filter + else: + data_filter = { + **base_data_filter, + "timestamp": { + "$gt": datetime.strptime(last_data_timestamp, DEFAULT_DATE_FORMAT) + } + } + + self.debug( + f"Data filter: {data_filter}", + metadata=metadata + ) + + data = self.find(collection_name, data_filter) + + self.debug( + f"Collected: {data}", + metadata=metadata + ) + + for item in data: + item['timestamp'] = item['timestamp'].strftime( + DEFAULT_DATE_FORMAT) + + self.info( + f"Loaded {len(data)} documents from MongoDB", + metadata=metadata + ) + + self.debug( + f"Loaded data: {data}", + metadata=metadata + ) + + return data + except Exception as e: + trace = traceback.format_exc() + self.send_notification( + metadata=metadata, + notification_id="MONGO_LOAD_ERROR", + message=f"Error loading data from MongoDB: {e}", + block="load_latest_data", + level=NotificationLevel.ERROR, + attachment_content=trace + ) + raise e diff --git a/orchestrator/activities/slot_manager.py b/orchestrator/activities/slot_manager.py index 404c8d8..b5cf4b7 100644 --- a/orchestrator/activities/slot_manager.py +++ b/orchestrator/activities/slot_manager.py @@ -1,3 +1,4 @@ +from pandas import DataFrame from temporalio import activity, workflow with workflow.unsafe.imports_passed_through(): @@ -193,3 +194,72 @@ class SlotManager(Redis): f"Report: \n {json.dumps(report, indent=4, sort_keys=True)}", metadata=metadata) return report + + @activity.defn(name="get_last_data_timestamp") + async def get_last_data_timestamp(self, input_data: dict[str, Any]) -> str | None: + """ + Gets the last data timestamp from redis. + """ + metadata = input_data['metadata'] + key = "notification_last_timestamp" + + try: + data_hold = self.get(key) + except Exception as e: + self.send_notification( + metadata=metadata, + notification_id="REDIS_GET_ERROR", + message=f"Error getting last data timestamp: {e}", + block="get_last_data_timestamp", + level=NotificationLevel.ERROR, + attachment_content=traceback.format_exc() + ) + raise e + + self.debug( + f"Last collected timestamp: {data_hold}", + metadata=metadata + ) + + if not data_hold: + return None + + return data_hold + + @activity.defn(name="put_last_data_timestamp") + async def put_last_data_timestamp(self, input_data: dict[str, Any]): + """ + Puts the last data timestamp into redis. + """ + metadata = input_data['metadata'] + key = "notification_last_timestamp" + + data = DataFrame(input_data['data']) + + if data.empty: + self.warning("No data to insert", + metadata=metadata + ) + return None + + last_data_timestamp = data['timestamp'].max() + + self.debug( + f"Last collected timestamp to insert: {last_data_timestamp}", + metadata=metadata + ) + + try: + self.set(key, last_data_timestamp, ttl=60*60*5) + except Exception as e: + self.send_notification( + metadata=metadata, + notification_id="REDIS_SET_ERROR", + message=f"Error setting last data timestamp: {e}", + block="put_last_data_timestamp", + level=NotificationLevel.ERROR, + attachment_content=traceback.format_exc() + ) + raise e + + return last_data_timestamp diff --git a/orchestrator/utils/email_builder.py b/orchestrator/utils/email_builder.py new file mode 100644 index 0000000..8dc59e8 --- /dev/null +++ b/orchestrator/utils/email_builder.py @@ -0,0 +1,75 @@ +import json +from sientia_do.temporal.utils.logger import Logger +from sientia_do.notifications.models import NotificationLevel +from jinja2 import Template +import re + + +class EmailBuilder: + def __init__(self, logger: Logger): + self.logger = logger + + self.report_template_file = './orchestrator/utils/templates/email_template.html' + self.general_template_file = './orchestrator/utils/templates/general_template.html' + + self.tag_template_file = './orchestrator/utils/templates/opc_tag_report_template.html' + self.opc_template_file = './orchestrator/utils/templates/opc_connection_report_template.html' + + self.partition_manager_template_file = './orchestrator/utils/templates/partition_manager_report_template.html' + + with open(self.report_template_file, 'r') as file: + self.report_template = file.read() + with open(self.general_template_file, 'r') as file: + self.general_template = file.read() + + def replace_parameters(self, template: str, parameters: dict) -> str: + # Criar um template Jinja2 + template = Template(template) + + return template.render(parameters) + + def parameters(self, report_data: dict, general_events: dict, mail_type: str) -> dict: + return { + 'project_name': report_data[0]['project'], + 'mail_type': mail_type, + 'error_events': self.replace_parameters(self.general_template, + general_events['ERROR']) if general_events['ERROR']['models'] else '', + 'warning_events': self.replace_parameters(self.general_template, + general_events['WARNING']) if general_events['WARNING']['models'] else '', + 'info_events': self.replace_parameters(self.general_template, + general_events['INFO']) if general_events['INFO']['models'] else '', + } + + def build_email(self, report_data: list[dict], mail_type: str) -> str: + """ + Builds the email html. + """ + general_events = {} + + for report in report_data: + + level = report['level'] + model_name = report['model_name'] + + if level not in general_events: + general_events[level] = { + 'section_name': f'{level.capitalize()}s detected:', + 'models': {} + } + + if model_name not in general_events[level]['models']: + general_events[level]['models'][model_name] = { + 'model_name': model_name, + 'events': [] + } + + general_events[level]['models'][model_name]['events'].append( + report) + + print(general_events) + for _type, content in general_events.items(): + content['models'] = list(content['models'].values()) + + return self.replace_parameters(self.report_template, self.parameters( + report_data, general_events, mail_type + )) diff --git a/orchestrator/utils/templates/email_template.html b/orchestrator/utils/templates/email_template.html new file mode 100644 index 0000000..e269e53 --- /dev/null +++ b/orchestrator/utils/templates/email_template.html @@ -0,0 +1,24 @@ + + + + + + SIENTIA™ Report + + + +

SIENTIA™ {{ mail_type }}

+ + {{ error_events }} + {{ warning_events }} + {{ info_events }} + + {{ special_events }} + + diff --git a/orchestrator/utils/templates/general_template.html b/orchestrator/utils/templates/general_template.html new file mode 100644 index 0000000..7736178 --- /dev/null +++ b/orchestrator/utils/templates/general_template.html @@ -0,0 +1,24 @@ +

{{ section_name }}

+{% for model in models %} +

Model: {{ model.model_name }}

+ + + + + + + + + + + {% for event in model.events %} + + + + + + + {% endfor %} + +
Notification IDBlockTimestampMessage
{{ event.notification_id }}{{ event.block }}{{ event.timestamp }}{{ event.message }}
+{% endfor %} \ No newline at end of file diff --git a/orchestrator/workflows/alerts.py b/orchestrator/workflows/alerts.py new file mode 100644 index 0000000..eee7f1c --- /dev/null +++ b/orchestrator/workflows/alerts.py @@ -0,0 +1,22 @@ +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from orchestrator.activities.activities import Activities + from typing import Any + + +@workflow.defn(name="alerts") +class Alerts: + @workflow.run + async def run(self, input_data: dict[str, Any]): + # Call subworkflow "load_notification_package" passing the static filters + # (level = "ERROR" and timestamp > last timestamp) + + # Filter notification package by groups custom configs, levels and + # timestamp cached + + # Call subworkflow "process_notifications" passing the notification package + + # Store the notification_id sendings to avoid sending them again + + pass diff --git a/orchestrator/workflows/orchestrator.py b/orchestrator/workflows/orchestrator.py index f61587d..cb81d13 100644 --- a/orchestrator/workflows/orchestrator.py +++ b/orchestrator/workflows/orchestrator.py @@ -11,6 +11,15 @@ with workflow.unsafe.imports_passed_through(): class Orchestrator: @workflow.run async def run(self, input_data: dict[str, Any]): + """ + Orchestrates the pipeline and slot management. Gets configuration from MongoDB and Redis, + creates the configuration and deploys the schedules and slots in the Temporal server and + Redis server. + input_data: + - schedule_name (str): The name of the schedule. + - pipelines_query (dict): The query to get the pipelines. + - opc_servers_query (dict): The query to get the OPC servers. + """ input_data['workflow_name'] = 'orchestrator' diff --git a/orchestrator/workflows/reports.py b/orchestrator/workflows/reports.py new file mode 100644 index 0000000..002034f --- /dev/null +++ b/orchestrator/workflows/reports.py @@ -0,0 +1,19 @@ +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from orchestrator.activities.activities import Activities + from typing import Any + + +@workflow.defn(name="reports") +class Reports: + @workflow.run + async def run(self, input_data: dict[str, Any]): + # Call subworkflow "load_notification_package" passing the static filters + # (timestamp > last timestamp) + + # Filter notification package by groups custom configs + + # Call subworkflow "process_notifications" passing the notification package + + pass diff --git a/orchestrator/workflows/subworkflows/load_notification_package.py b/orchestrator/workflows/subworkflows/load_notification_package.py new file mode 100644 index 0000000..165a778 --- /dev/null +++ b/orchestrator/workflows/subworkflows/load_notification_package.py @@ -0,0 +1,93 @@ +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from orchestrator.activities.activities import Activities + from typing import Any + from sientia_do.temporal.utils.policies import retry_policy + from datetime import timedelta + + +@workflow.defn(name="load_notification_package") +class LoadNotificationPackage: + @workflow.run + async def run(self, input_data: dict[str, Any]): + """ + Loads the notification package from the MongoDB collection "notification_queue" + and the sending configs from the MongoDB collection "receiver_groups". + + input_data: + - metadata (dict): The metadata of the workflow. + + returns: + - last_timestamp (str): The last timestamp of the notification package. + - notification_package (list[dict]): The notification package. + - sending_configs (list[dict]): The sending configs. + """ + metadata = input_data['metadata'] + + # Load last timestamp from redis "notification_last_timestamp" + last_timestamp_handler = workflow.start_local_activity_method( + Activities.get_last_data_timestamp, + { + **metadata, + }, + start_to_close_timeout=timedelta(seconds=60), + retry_policy=retry_policy + ) + + # In parallel, load sending configs from collection "receiver_groups" + sending_configs_handler = workflow.start_local_activity_method( + Activities.find_documents_in_mongodb, + { + **metadata, + 'query': { + 'collection': 'receiver_groups', + 'filters': { + 'active': True + } + } + }, + start_to_close_timeout=timedelta(seconds=60), + retry_policy=retry_policy + ) + + last_timestamp = await last_timestamp_handler + + # Load notification package from collection "notification_queue", using a + # static filter + + notification_package = await workflow.start_local_activity_method( + Activities.load_latest_data, + { + **metadata, + 'collection_name': 'notification_queue', + 'last_data_timestamp': last_timestamp, + 'base_data_filter': { + 'level': 'ERROR' + } + }, + start_to_close_timeout=timedelta(seconds=60), + retry_policy=retry_policy + ) + + # Put last collected timestamp in redis "notification_last_timestamp" + + await workflow.start_activity_method( + Activities.put_last_data_timestamp, + { + **metadata, + 'data': notification_package + }, + start_to_close_timeout=timedelta(seconds=60), + retry_policy=retry_policy + ) + + # Return a dict with the following keys: + # - last_timestamp + # - notification_package + # - sending_configs + return { + 'last_timestamp': last_timestamp, + 'notification_package': notification_package, + 'sending_configs': await sending_configs_handler + } diff --git a/orchestrator/workflows/subworkflows/process_notifications.py b/orchestrator/workflows/subworkflows/process_notifications.py new file mode 100644 index 0000000..de71b27 --- /dev/null +++ b/orchestrator/workflows/subworkflows/process_notifications.py @@ -0,0 +1,87 @@ +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from orchestrator.activities.activities import Activities + from typing import Any + from datetime import timedelta + from sientia_do.temporal.utils.policies import retry_policy + + +@workflow.defn(name="process_notifications") +class ProcessNotifications: + @workflow.run + async def run(self, input_data: dict[str, Any]) -> dict[str, Any]: + """ + Processes the notifications. Builds the report html for each group and each model, + sends the report html to the receivers of each group, stores the sending log in the + postgres database "log_report", and returns the log report to the caller. + + input_data: + - metadata (dict): The metadata of the workflow. + - mail_type (str): The mail type. + - schema (str): The schema of the table. + - table_name (str): The name of the table. + - notification_package (list[dict]): The notification package. the format of each + notification package is: + { + 'group_name' (str) + 'group_members' (list[str]) + 'notifications' (dict) + { + 'model_name' (dict[str, list[dict]]) + } + } + returns: + - log_report (dict) + """ + + metadata = input_data["metadata"] + + # Use notification package to create the report html for each group and each model + data_to_sent = await workflow.execute_local_activity_method( + Activities.build_email_html, + { + **metadata, + "receiver_groups": input_data["notification_package"] + }, + schedule_to_close_timeout=timedelta(seconds=60), + retry_policy=retry_policy + ) + + # Send the report html to the receivers of each group + log_report = await workflow.execute_activity_method( + Activities.send_email, + { + **metadata, + "receiver_groups": data_to_sent, + "mail_type": input_data["mail_type"] + }, + schedule_to_close_timeout=timedelta(seconds=60), + retry_policy=retry_policy + ) + + # Format the log report to a dataframe to be stored in the database + log_report = await workflow.execute_local_activity_method( + Activities.format_log_report, + { + **metadata, + "receiver_groups": log_report, + "mail_type": input_data["mail_type"] + }, + ) + + # Store sending log in postgres database "log_report" + await workflow.execute_activity_method( + Activities.export_data_to_postgres, + { + **metadata, + "schema": input_data["schema"], + "table_name": input_data["table_name"], + "data": log_report + }, + schedule_to_close_timeout=timedelta(seconds=60), + retry_policy=retry_policy + ) + + # Return the log report to the caller + return log_report diff --git a/requirements.txt b/requirements.txt index 23d31f9..d06bf0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ sqlalchemy redis couchbase pymongo +jinja2 git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.3.4 diff --git a/tests/orchestrator/activities/test_mongo_db.py b/tests/orchestrator/activities/test_mongo_db.py index 707b866..4c4c956 100644 --- a/tests/orchestrator/activities/test_mongo_db.py +++ b/tests/orchestrator/activities/test_mongo_db.py @@ -1,4 +1,5 @@ from curses import meta +from datetime import datetime from unittest.mock import MagicMock, patch, ANY from pytest import fixture, mark from orchestrator.activities.mongo_db import clear_mongo_id @@ -518,3 +519,133 @@ async def test_create_collection_with_ttl_index_failure(mongo_db): ) else: assert False, "Expected an exception to be raised" + + +@mark.asyncio +async def test_load_latest_data_none_last_data_timestamp(mongo_db): + """Test load_latest_data""" + collection = MagicMock() + mongo_db.database.__getitem__.return_value = collection + + collection.find.return_value = [ + { + 'name': 'test1', + 'value': 1, + 'timestamp': datetime.strptime( + '2023-01-01 12:00:00.000000', '%Y-%m-%d %H:%M:%S.%f') + } + ] + + result = await mongo_db.load_latest_data({ + 'metadata': {'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule'}, + 'collection_name': 'test_collection', + 'last_data_timestamp': None, + 'base_data_filter': { + 'level': 'ERROR' + } + }) + + mongo_db.database.__getitem__.assert_called_once_with( + 'test_collection') + + collection.find.assert_called_once_with( + { + 'level': 'ERROR' + }, + {"_id": 0} + ) + + assert result == { + 'name': { + 0: 'test1' + }, + 'value': { + 0: 1 + }, + 'timestamp': { + 0: '2023-01-01 12:00:00.000000' + } + } + + +@mark.asyncio +async def test_load_latest_data_not_none_last_data_timestamp(mongo_db): + """Test load_latest_data""" + collection = MagicMock() + mongo_db.database.__getitem__.return_value = collection + + collection.find.return_value = [ + { + 'name': 'test1', + 'value': 1, + 'timestamp': datetime.strptime( + '2023-01-01 12:00:00.000000', '%Y-%m-%d %H:%M:%S.%f') + } + ] + + result = await mongo_db.load_latest_data({ + 'metadata': {'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule'}, + 'collection_name': 'test_collection', + 'last_data_timestamp': '2023-01-01 12:00:00.000000', + 'base_data_filter': { + 'level': 'ERROR' + } + }) + + mongo_db.database.__getitem__.assert_called_once_with( + 'test_collection') + + collection.find.assert_called_once_with( + { + 'level': 'ERROR', + 'timestamp': { + '$gt': datetime.strptime( + '2023-01-01 12:00:00.000000', '%Y-%m-%d %H:%M:%S.%f') + } + }, + {"_id": 0} + ) + + assert result == { + 'name': { + 0: 'test1' + }, + 'value': { + 0: 1 + }, + 'timestamp': { + 0: '2023-01-01 12:00:00.000000' + } + } + + +@mark.asyncio +async def test_load_latest_data_error(mongo_db): + """Test load_latest_data""" + collection = MagicMock() + mongo_db.send_notification = MagicMock() + mongo_db.database.__getitem__.return_value = collection + + collection.find.side_effect = Exception('test') + + try: + await mongo_db.load_latest_data({ + 'metadata': {'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule'}, + 'collection_name': 'test_collection', + 'last_data_timestamp': '2023-01-01 12:00:00.000000', + 'base_data_filter': { + 'level': 'ERROR' + } + }) + except Exception as e: + assert str(e) == 'test' + + mongo_db.send_notification.assert_called_once_with( + metadata={'workflow_name': 'test_pipeline', + 'schedule_name': 'test_schedule'}, + notification_id='MONGO_LOAD_ERROR', + message='Error loading data from MongoDB: test', + block='load_latest_data', + level=NotificationLevel.ERROR, + attachment_content=ANY + ) diff --git a/tests/orchestrator/activities/test_slot_manager.py b/tests/orchestrator/activities/test_slot_manager.py index 9be7686..ad2e9a5 100644 --- a/tests/orchestrator/activities/test_slot_manager.py +++ b/tests/orchestrator/activities/test_slot_manager.py @@ -1,4 +1,5 @@ from unittest.mock import MagicMock, patch, call, ANY +from pandas import DataFrame from pytest import mark, fixture from orchestrator.activities.slot_manager import SlotManager from sientia_do.notifications.models import NotificationLevel @@ -206,3 +207,155 @@ async def test_delete_slots(slot_manager): "message": "Test exception" } } + + +@mark.asyncio +async def test_get_last_data_timestamp_none(slot_manager): + """Test get_last_data_timestamp""" + test_data = { + **metadata, + 'workflow_name': 'test_pipeline', + 'schedule_name': 'test_schedule' + } + + slot_manager.get = MagicMock(return_value=None) + + result = await slot_manager.get_last_data_timestamp(test_data) + + assert result is None + + +@mark.asyncio +async def test_get_last_data_timestamp_not_none(slot_manager): + """Test get_last_data_timestamp""" + test_data = { + **metadata, + 'workflow_name': 'test_pipeline', + 'schedule_name': 'test_schedule' + } + + slot_manager.get = MagicMock(return_value='2023-01-01 12:00:00') + + result = await slot_manager.get_last_data_timestamp(test_data) + + slot_manager.get.assert_called_once_with( + 'notification_last_timestamp' + ) + + assert result == '2023-01-01 12:00:00' + + +@mark.asyncio +async def test_get_last_data_timestamp_error(slot_manager): + """Test get_last_data_timestamp error""" + test_data = { + **metadata, + 'workflow_name': 'test_pipeline', + 'schedule_name': 'test_schedule' + } + + slot_manager.send_notification = MagicMock() + slot_manager.get = MagicMock(side_effect=Exception('test')) + + try: + + await slot_manager.get_last_data_timestamp(test_data) + + except Exception as e: + assert str(e) == 'test' + + slot_manager.send_notification.assert_called_once_with( + metadata=metadata['metadata'], + notification_id="REDIS_GET_ERROR", + message="Error getting last data timestamp: test", + block="get_last_data_timestamp", + level=NotificationLevel.ERROR, + attachment_content=ANY + ) + + else: + assert False, "Expected exception" + + +@mark.asyncio +async def test_put_last_data_timestamp_empty_dataframe(slot_manager): + """Test put_last_data_timestamp with empty dataframe""" + test_data = { + **metadata, + 'workflow_name': 'test_pipeline', + 'schedule_name': 'test_schedule', + 'data': DataFrame(columns=['name', 'value', 'timestamp']).to_dict('records') + } + + slot_manager.set = MagicMock() + + result = await slot_manager.put_last_data_timestamp(test_data) + + assert result is None + + slot_manager.set.assert_not_called() + + +@mark.asyncio +async def test_put_last_data_timestamp_not_empty_dataframe(slot_manager): + """Test put_last_data_timestamp with not empty dataframe""" + + data = DataFrame({ + 'name': ['sensor1', 'sensor2'], + 'value': [25.5, 30.0], + 'timestamp': ['2023-01-01 12:00:00', '2023-01-01 12:00:01'] + }) + test_data = { + **metadata, + 'workflow_name': 'test_pipeline', + 'schedule_name': 'test_schedule', + 'data': data.to_dict('records') + } + + slot_manager.set = MagicMock() + + result = await slot_manager.put_last_data_timestamp(test_data) + + assert result == '2023-01-01 12:00:01' + + slot_manager.set.assert_called_once_with( + 'notification_last_timestamp', + '2023-01-01 12:00:01', + ttl=18000 + ) + + +@mark.asyncio +async def test_put_last_data_timestamp_error(slot_manager): + """Test put_last_data_timestamp error""" + test_data = { + **metadata, + 'workflow_name': 'test_pipeline', + 'schedule_name': 'test_schedule', + 'data': DataFrame({ + 'name': ['sensor1', 'sensor2'], + 'value': [25.5, 30.0], + 'timestamp': ['2023-01-01 12:00:00'] * 2 + }).to_dict('records') + } + + slot_manager.send_notification = MagicMock() + slot_manager.set = MagicMock(side_effect=Exception('test')) + + try: + await slot_manager.put_last_data_timestamp(test_data) + + except Exception as e: + assert str(e) == 'test' + + slot_manager.send_notification.assert_called_once_with( + metadata=metadata['metadata'], + notification_id="REDIS_SET_ERROR", + message="Error setting last data timestamp: test", + block="put_last_data_timestamp", + level=NotificationLevel.ERROR, + attachment_content=ANY + ) + + else: + assert False, "Expected exception" diff --git a/tests/orchestrator/workflows/subworkflows/test_load_notification_package.py b/tests/orchestrator/workflows/subworkflows/test_load_notification_package.py new file mode 100644 index 0000000..952cd3f --- /dev/null +++ b/tests/orchestrator/workflows/subworkflows/test_load_notification_package.py @@ -0,0 +1,115 @@ +from unittest.mock import AsyncMock, patch, ANY, call +from pytest import fixture, mark +from orchestrator.workflows.subworkflows.load_notification_package import LoadNotificationPackage +from orchestrator.activities.activities import Activities + + +@fixture +def load_notification_package(): + return LoadNotificationPackage() + + +metadata = { + 'metadata': { + 'schedule_name': 'test-schedule-name', + 'workflow_name': 'test-workflow', + 'model_name': '-', + 'model_id': '-', + } +} + + +@mark.asyncio +@patch("orchestrator.workflows.subworkflows.load_notification_package.workflow", new_callable=AsyncMock) +async def test_run(workflow_mock, load_notification_package): + input_data = { + 'metadata': metadata + } + + workflow_mock.start_local_activity_method.side_effect = [ + '2023-01-01 12:00:00', + [ + { + 'id': '1', + } + ], + [ + { + 'id_r': '1', + } + ] + ] + + output = await load_notification_package.run(input_data) + + assert output == { + 'last_timestamp': '2023-01-01 12:00:00', + 'notification_package': [ + { + 'id': '1', + } + ], + 'sending_configs': [ + { + 'id_r': '1', + } + ] + } + + workflow_mock.start_local_activity_method.assert_has_calls([ + call( + Activities.get_last_data_timestamp, + input_data['metadata'], + start_to_close_timeout=ANY, + retry_policy=ANY + ) + ]) + + workflow_mock.start_local_activity_method.assert_has_calls([ + call( + Activities.find_documents_in_mongodb, + { + **input_data['metadata'], + 'query': { + 'collection': 'receiver_groups', + 'filters': { + 'active': True + } + } + }, + start_to_close_timeout=ANY, + retry_policy=ANY + ) + ]) + + workflow_mock.start_local_activity_method.assert_has_calls([ + call( + Activities.load_latest_data, + { + **input_data['metadata'], + 'collection_name': 'notification_queue', + 'last_data_timestamp': '2023-01-01 12:00:00', + 'base_data_filter': { + 'level': 'ERROR' + } + }, + start_to_close_timeout=ANY, + retry_policy=ANY + ) + ]) + + workflow_mock.start_activity_method.assert_has_calls([ + call( + Activities.put_last_data_timestamp, + { + **input_data['metadata'], + 'data': [ + { + 'id': '1', + } + ] + }, + start_to_close_timeout=ANY, + retry_policy=ANY + ) + ]) From f2cb0f057d0f2b8a43a47e6b405ad5fc8d89c733 Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Fri, 25 Jul 2025 16:51:56 -0300 Subject: [PATCH 02/14] SIENTIAPDE-1172 refactor: simplify EmailBuilder parameters and remove unused templates - Removed unused template file attributes from EmailBuilder class. - Updated parameters method to eliminate report_data argument, streamlining the function for better clarity and efficiency. --- orchestrator/utils/email_builder.py | 16 +- .../orchestrator/utils/test_email_builder.py | 145 ++++++++++++++++++ 2 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 tests/orchestrator/utils/test_email_builder.py diff --git a/orchestrator/utils/email_builder.py b/orchestrator/utils/email_builder.py index 8dc59e8..0aa2881 100644 --- a/orchestrator/utils/email_builder.py +++ b/orchestrator/utils/email_builder.py @@ -12,11 +12,6 @@ class EmailBuilder: self.report_template_file = './orchestrator/utils/templates/email_template.html' self.general_template_file = './orchestrator/utils/templates/general_template.html' - self.tag_template_file = './orchestrator/utils/templates/opc_tag_report_template.html' - self.opc_template_file = './orchestrator/utils/templates/opc_connection_report_template.html' - - self.partition_manager_template_file = './orchestrator/utils/templates/partition_manager_report_template.html' - with open(self.report_template_file, 'r') as file: self.report_template = file.read() with open(self.general_template_file, 'r') as file: @@ -28,9 +23,8 @@ class EmailBuilder: return template.render(parameters) - def parameters(self, report_data: dict, general_events: dict, mail_type: str) -> dict: + def parameters(self, general_events: dict, mail_type: str) -> dict: return { - 'project_name': report_data[0]['project'], 'mail_type': mail_type, 'error_events': self.replace_parameters(self.general_template, general_events['ERROR']) if general_events['ERROR']['models'] else '', @@ -66,10 +60,10 @@ class EmailBuilder: general_events[level]['models'][model_name]['events'].append( report) - print(general_events) for _type, content in general_events.items(): content['models'] = list(content['models'].values()) - return self.replace_parameters(self.report_template, self.parameters( - report_data, general_events, mail_type - )) + return self.replace_parameters( + self.report_template, self.parameters( + general_events, mail_type + )) diff --git a/tests/orchestrator/utils/test_email_builder.py b/tests/orchestrator/utils/test_email_builder.py new file mode 100644 index 0000000..d2145be --- /dev/null +++ b/tests/orchestrator/utils/test_email_builder.py @@ -0,0 +1,145 @@ +import json +from unittest.mock import MagicMock, patch +from pytest import fixture + +from orchestrator.utils.email_builder import EmailBuilder + + +@fixture +@patch('orchestrator.utils.email_builder.open') +def report_builder(open): + return EmailBuilder(MagicMock()) + + +@patch('orchestrator.utils.email_builder.Template') +def test_replace_parameters(template, report_builder): + output = report_builder.replace_parameters('template', {'key': 'value'}) + + assert output == template.return_value.render.return_value + + template.assert_called_once_with('template') + template.return_value.render.assert_called_once_with({'key': 'value'}) + + +def test_parameters(report_builder): + report_builder.replace_parameters = MagicMock() + general_events = { + 'ERROR': { + 'models': [ + {'model_name': 'model_name', 'events': [ + {'notification_id': 'ID_1', 'level': 'ERROR', 'project': 'project'}]}, + ] + }, + 'WARNING': { + 'models': [ + {'model_name': 'model_name', 'events': [ + {'notification_id': 'ID_2', 'level': 'WARNING', 'project': 'project'}]}, + ] + }, + 'INFO': { + 'models': [ + {'model_name': 'model_name', 'events': [ + {'notification_id': 'ID_3', 'level': 'INFO', 'project': 'project'}]}, + ] + } + } + + output = report_builder.parameters(general_events, 'model_name') + + assert output == { + 'mail_type': 'model_name', + 'error_events': report_builder.replace_parameters.return_value, + 'warning_events': report_builder.replace_parameters.return_value, + 'info_events': report_builder.replace_parameters.return_value, + } + + report_builder.replace_parameters.assert_any_call( + report_builder.general_template, + {'models': [ + {'model_name': 'model_name', 'events': [ + {'notification_id': 'ID_1', 'level': 'ERROR', 'project': 'project'}]}, + ]} + ) + report_builder.replace_parameters.assert_any_call( + report_builder.general_template, + {'models': [ + {'model_name': 'model_name', 'events': [ + {'notification_id': 'ID_2', 'level': 'WARNING', 'project': 'project'}]}, + ]} + ) + report_builder.replace_parameters.assert_any_call( + report_builder.general_template, + {'models': [ + {'model_name': 'model_name', 'events': [ + {'notification_id': 'ID_3', 'level': 'INFO', 'project': 'project'}]}, + ]} + ) + + +def test_build_email(report_builder): + report_builder.parameters = MagicMock() + report_builder.replace_parameters = MagicMock() + + report_data = [ + {'notification_id': 'ID_1', 'level': 'ERROR', + 'project': 'project', 'model_name': 'model_name'}, + {'notification_id': 'ID_2', 'level': 'WARNING', + 'project': 'project', 'model_name': 'model_name'}, + {'notification_id': 'ID_2', 'level': 'INFO', + 'project': 'project', 'model_name': 'model_name'}, + {'notification_id': 'ID_3', 'level': 'ERROR', + 'project': 'project', 'model_name': 'model_name'} + ] + + html = report_builder.build_email(report_data, 'type_1') + + report_builder.replace_parameters.assert_called_once_with( + report_builder.report_template, + report_builder.parameters.return_value + ) + + assert html == report_builder.replace_parameters.return_value + + report_builder.parameters.assert_called_once_with( + { + 'ERROR': { + 'section_name': 'Errors detected:', + 'models': [ + { + 'model_name': 'model_name', + 'events': [ + {'notification_id': 'ID_1', 'level': 'ERROR', + 'project': 'project', 'model_name': 'model_name'}, + {'notification_id': 'ID_3', 'level': 'ERROR', + 'project': 'project', 'model_name': 'model_name'} + ] + } + ] + }, + 'WARNING': { + 'section_name': 'Warnings detected:', + 'models': [ + { + 'model_name': 'model_name', + 'events': [ + {'notification_id': 'ID_2', 'level': 'WARNING', + 'project': 'project', 'model_name': 'model_name'} + ] + } + ] + }, + 'INFO': { + 'section_name': 'Infos detected:', + 'models': [ + { + 'model_name': 'model_name', + 'events': [ + {'notification_id': 'ID_2', 'level': 'INFO', + 'project': 'project', 'model_name': 'model_name'} + ] + } + ] + } + }, + 'type_1' + ) From ab657d0fda40c39388b5e0d5863ce9ddd8b75827 Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Mon, 28 Jul 2025 15:07:09 -0300 Subject: [PATCH 03/14] SIENTIAPDE-1172 feat: enhance email and slot manager activities with new filtering and caching functionalities - Updated Email class to improve attachment handling and logging. - Refactored Formatters class to process notifications more effectively by grouping them based on their configurations. - Introduced new methods in SlotManager for filtering notification alerts and storing notification cache. - Enhanced Alerts workflow to integrate new filtering and caching activities. - Added tests for new functionalities in SlotManager and Formatters classes to ensure reliability. --- orchestrator/activities/email.py | 16 +- orchestrator/activities/formatters.py | 44 +-- orchestrator/activities/slot_manager.py | 76 ++++- orchestrator/workflows/alerts.py | 70 ++++- .../subworkflows/load_notification_package.py | 11 +- tests/orchestrator/activities/test_email.py | 277 ++++++++++++++++++ .../activities/test_formatters.py | 69 +++++ .../activities/test_slot_manager.py | 97 ++++++ .../test_process_notifications.py | 79 +++++ tests/orchestrator/workflows/test_alerts.py | 80 +++++ 10 files changed, 786 insertions(+), 33 deletions(-) create mode 100644 tests/orchestrator/activities/test_email.py create mode 100644 tests/orchestrator/workflows/subworkflows/test_process_notifications.py create mode 100644 tests/orchestrator/workflows/test_alerts.py diff --git a/orchestrator/activities/email.py b/orchestrator/activities/email.py index 3355ebb..4ec30cf 100644 --- a/orchestrator/activities/email.py +++ b/orchestrator/activities/email.py @@ -1,9 +1,8 @@ -from email import encoders -from email.mime.base import MIMEBase -import traceback + from temporalio import workflow, activity with workflow.unsafe.imports_passed_through(): + import traceback import smtplib from typing import Any from sientia_do.temporal.activities.base import BaseActivity @@ -12,6 +11,8 @@ with workflow.unsafe.imports_passed_through(): from orchestrator.utils.email_builder import EmailBuilder from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText + from email.mime.base import MIMEBase + from email import encoders class Email(BaseActivity): @@ -24,7 +25,6 @@ class Email(BaseActivity): self.sender_email = sender_email self.sender_password = sender_password self.port = port - self.logger = logger if self.sender_password: self.server = smtplib.SMTP_SSL(smpt_server, port) @@ -68,7 +68,7 @@ class Email(BaseActivity): Attaches a list of attachments to an email message. Args: attachments (List[Dict]): A list of dictionaries where each dictionary contains - the keys 'attachment_id', 'trigger', and 'timestamp' representing the attachment details. + the keys 'filename' and 'content' representing the attachment details. msg (MIMEMultipart): The email message object to which the attachments will be added. Returns: MIMEMultipart: The email message object with the attachments added. @@ -124,7 +124,11 @@ class Email(BaseActivity): msg['Subject'] = f"SIENTIA™ {mail_type}" msg = self.handle_attachments( - [notification['attachment_content'] + [ + { + "filename": f"{notification['trigger']}_{notification['notification_id']}.txt", + "content": notification['attachment_content'] + } for notification in group_config['notifications'] if notification['attachment_content']], msg) diff --git a/orchestrator/activities/formatters.py b/orchestrator/activities/formatters.py index 9557b31..635c613 100644 --- a/orchestrator/activities/formatters.py +++ b/orchestrator/activities/formatters.py @@ -508,28 +508,30 @@ class Formatters(BaseActivity): for group_name, group_config in receiver_groups.items(): - notification_id = group_config['notification_id'] - trigger = group_config['trigger'] + for notification in group_config['notifications']: - key = f"{notification_id}:{trigger}" + notification_id = notification['notification_id'] + trigger = notification['trigger'] - if key not in data: - data[key] = { - 'status': group_config['status'], - 'timestamp': group_config['timestamp'], - 'groups': [group_name], - 'message': group_config['message'], - 'level': group_config['level'], - 'notification_id': notification_id, - 'block': group_config['block'], - 'schedule': trigger, - 'pipeline': group_config['pipeline'], - 'project': group_config['project'], - 'model_name': group_config['model_name'], - 'model_id': group_config['model_id'], - 'mail_type': mail_type - } - else: - data[key]['groups'].append(group_name) + key = f"{notification_id}:{trigger}" + + if key not in data: + data[key] = { + 'status': group_config['status'], + 'timestamp': notification['timestamp'], + 'groups': [group_name], + 'message': notification['message'], + 'level': notification['level'], + 'notification_id': notification_id, + 'block': notification['block'], + 'schedule': trigger, + 'pipeline': notification['pipeline'], + 'project': notification['project'], + 'model_name': notification['model_name'], + 'model_id': notification['model_id'], + 'mail_type': mail_type + } + else: + data[key]['groups'].append(group_name) return DataFrame(list(data.values())).to_dict() diff --git a/orchestrator/activities/slot_manager.py b/orchestrator/activities/slot_manager.py index b5cf4b7..eb3554f 100644 --- a/orchestrator/activities/slot_manager.py +++ b/orchestrator/activities/slot_manager.py @@ -1,4 +1,4 @@ -from pandas import DataFrame + from temporalio import activity, workflow with workflow.unsafe.imports_passed_through(): @@ -9,6 +9,9 @@ with workflow.unsafe.imports_passed_through(): from sientia_do.temporal.activities.redis_base import Redis from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler from sientia_do.notifications.models import NotificationLevel + from orchestrator.utils.patterns import DEFAULT_DATE_FORMAT + from pandas import DataFrame + from datetime import datetime, timedelta class SlotManager(Redis): @@ -263,3 +266,74 @@ class SlotManager(Redis): raise e return last_data_timestamp + + @activity.defn(name="filter_notification_alerts") + async def filter_notification_alerts(self, input_data: dict[str, Any]) -> dict[str, Any]: + """ + Filter notification alerts + """ + metadata = input_data['metadata'] + notification_package = input_data['notification_package'] + sending_configs = input_data['sending_configs'] + notification_ttl = input_data['notification_ttl'] + + self.info("Filtering notification alerts...", metadata=metadata) + + receiver_groups = {} + + for receiver_group in sending_configs: + group_name = receiver_group['group_name'] + receiver_groups[group_name] = { + **receiver_group, + "notifications": [] + } + receiver_groups[group_name]['notifications'] = [] + + ignore_list = receiver_group.get('ignore', []) + + for notification in notification_package: + alert_type = "do_nothing" + notification_id = notification['notification_id'] + # Check if notification was recently sent + key = f"{notification['trigger']}:{notification_id}" + + last_sent = self.get(key) + + if last_sent is None: + alert_type = "core_alerts" + + else: + last_sent = datetime.strptime( + last_sent, DEFAULT_DATE_FORMAT) + + # Check if "notification_ttl" seconds has passed since last sent + if (datetime.now() - last_sent) > timedelta(seconds=notification_ttl): + alert_type = "persistent_alerts" + + # Check if this group must be notified + if alert_type in receiver_group['contents'] and notification_id not in ignore_list: + receiver_groups[group_name]["notifications"].append( + notification) + + return receiver_groups + + @activity.defn(name="store_notification_cache") + async def store_notification_cache(self, input_data: dict[str, Any]) -> dict[str, Any]: + """ + Store notification cache + """ + metadata = input_data['metadata'] + log_report = DataFrame(input_data['log_report']) + sent_ttl = input_data['sent_ttl'] + + self.info("Storing notification cache...", metadata=metadata) + + now = datetime.now().strftime(DEFAULT_DATE_FORMAT) + + for index, row in log_report.iterrows(): + status = row['status'] + if status == 'sent': + key = f"{row['schedule']}:{row['notification_id']}" + self.set(key, now, ttl=sent_ttl) + + return log_report diff --git a/orchestrator/workflows/alerts.py b/orchestrator/workflows/alerts.py index eee7f1c..fd46f57 100644 --- a/orchestrator/workflows/alerts.py +++ b/orchestrator/workflows/alerts.py @@ -9,14 +9,80 @@ with workflow.unsafe.imports_passed_through(): class Alerts: @workflow.run async def run(self, input_data: dict[str, Any]): + """ + Workflow to send alerts to the users + + Args: + input_data (dict[str, Any]): Input data. It contains the following keys: + - schedule_name: str - Name of the schedule + - notification_ttl: int - Period before consider some notification persistent + - sent_ttl: int - Time to live for the sent notification + + Returns: + None + + Raises: + Exception: If the workflow fails + """ + metadata = { + 'metadata': { + 'schedule_name': input_data['schedule_name'], + 'workflow_name': 'alerts', + 'model_name': '-', + 'model_id': '-' + } + } + + mail_type = "Alerts" + + input_data['metadata'] = metadata + + input_data['base_data_filter'] = { + 'level': 'ERROR' + } + # Call subworkflow "load_notification_package" passing the static filters # (level = "ERROR" and timestamp > last timestamp) + package = await workflow.execute_child_workflow( + 'load_notification_package', + input_data + ) + + if not package['notification_package'] or not package['sending_configs']: + return + # Filter notification package by groups custom configs, levels and # timestamp cached + receiver_groups = await workflow.execute_local_activity_method( + Activities.filter_notification_alerts, + { + **metadata, + 'notification_package': package['notification_package'], + 'sending_configs': package['sending_configs'], + 'notification_ttl': input_data['notification_ttl'] + } + ) + # Call subworkflow "process_notifications" passing the notification package + log_report = await workflow.execute_child_workflow( + 'process_notifications', + { + 'metadata': metadata, + 'mail_type': mail_type, + 'notification_package': receiver_groups, + 'schema': 'sientia_data', + 'table_name': 'log_report' + } + ) # Store the notification_id sendings to avoid sending them again - - pass + await workflow.execute_activity_method( + Activities.store_notification_cache, + { + **metadata, + 'log_report': log_report, + 'sent_ttl': input_data['sent_ttl'] + } + ) diff --git a/orchestrator/workflows/subworkflows/load_notification_package.py b/orchestrator/workflows/subworkflows/load_notification_package.py index 165a778..8cbc76b 100644 --- a/orchestrator/workflows/subworkflows/load_notification_package.py +++ b/orchestrator/workflows/subworkflows/load_notification_package.py @@ -62,14 +62,19 @@ class LoadNotificationPackage: **metadata, 'collection_name': 'notification_queue', 'last_data_timestamp': last_timestamp, - 'base_data_filter': { - 'level': 'ERROR' - } + 'base_data_filter': input_data['base_data_filter'] }, start_to_close_timeout=timedelta(seconds=60), retry_policy=retry_policy ) + if not notification_package: + return { + 'last_timestamp': last_timestamp, + 'notification_package': [], + 'sending_configs': await sending_configs_handler + } + # Put last collected timestamp in redis "notification_last_timestamp" await workflow.start_activity_method( diff --git a/tests/orchestrator/activities/test_email.py b/tests/orchestrator/activities/test_email.py new file mode 100644 index 0000000..e29c68e --- /dev/null +++ b/tests/orchestrator/activities/test_email.py @@ -0,0 +1,277 @@ +from unittest.mock import MagicMock, call, patch +from pytest import mark, fixture + +from orchestrator.activities.email import Email + + +@fixture +@patch('orchestrator.activities.email.EmailBuilder') +@patch('orchestrator.activities.email.smtplib') +def email(smtplib, email_builder): + email = Email( + sender_email="test@test.com", + sender_password="test", + smpt_server="test", + port=587, + logger=MagicMock(), + notification_handler=MagicMock() + ) + email_builder.send_notification = MagicMock() + + return email + + +@patch('orchestrator.activities.email.EmailBuilder') +@patch('orchestrator.activities.email.smtplib') +def test___init___with_password(smtplib, email_builder): + email = Email( + sender_email="test@test.com", + sender_password="test", + smpt_server="test", + port=587, + logger=MagicMock(), + notification_handler=MagicMock() + ) + + assert email.sender_email == "test@test.com" + assert email.sender_password == "test" + assert email.port == 587 + + smtplib.SMTP_SSL.assert_called_once_with("test", 587) + smtplib.SMTP_SSL.return_value.login.assert_called_once_with( + "test@test.com", "test") + + assert email.server == smtplib.SMTP_SSL.return_value + + +@patch('orchestrator.activities.email.EmailBuilder') +@patch('orchestrator.activities.email.smtplib') +def test___init___without_password(smtplib, email_builder): + email = Email( + sender_email="test@test.com", + sender_password=None, + smpt_server="test", + port=587, + logger=MagicMock(), + notification_handler=MagicMock() + ) + + assert email.sender_email == "test@test.com" + assert email.sender_password is None + assert email.port == 587 + + smtplib.SMTP.assert_called_once_with("test", 587) + assert email.server == smtplib.SMTP.return_value + + +metadata = { + "metadata": { + "schedule_name": "test", + "model_name": "test", + "model_id": "test", + "workflow_name": "test", + } +} + + +@mark.asyncio +async def test_build_email_html(email): + email.email_builder.build_email = MagicMock( + return_value="test" + ) + input_data = { + **metadata, + "receiver_groups": { + "group_1": { + "notifications": [ + { + "type": "test", + "subject": "test", + "body": "test" + }, + { + "type": "test", + "subject": "test", + "body": "test" + } + ] + } + }, + "mail_type": "test" + } + + response = await email.build_email_html(input_data) + + assert response == { + "group_1": { + "notifications": [ + { + "type": "test", + "subject": "test", + "body": "test", + }, + { + "type": "test", + "subject": "test", + "body": "test", + } + ], + "html": "test" + } + } + + email.email_builder.build_email.assert_called_once_with( + input_data['receiver_groups']['group_1']['notifications'], + input_data['mail_type'] + ) + + +@patch('orchestrator.activities.email.MIMEBase') +@patch('orchestrator.activities.email.encoders') +def test_handle_attachments_success(encoders, mime_base, email): + message = MagicMock() + + attachments = [ + { + "filename": "file_1", + "attachment_content": "test_content_1" + }, + { + "filename": "file_2", + "attachment_content": "test_content_2" + }, + { + "filename": "file_3", + "attachment_content": "test_content_3" + } + ] + + response = email.handle_attachments(attachments, message) + + assert response == message + + mime_base.assert_called_with('application', 'octet-stream') + assert mime_base.return_value.set_payload.call_count == 3 + + mime_base.return_value.set_payload.assert_has_calls( + [ + call("test_content_1".encode('utf-8')), + call("test_content_2".encode('utf-8')), + call("test_content_3".encode('utf-8')) + ] + ) + + encoders.encode_base64.assert_called_with(mime_base.return_value) + assert encoders.encode_base64.call_count == 3 + + mime_base.return_value.add_header.assert_has_calls( + [ + call('Content-Disposition', 'attachment; filename="file_1"'), + call('Content-Disposition', 'attachment; filename="file_2"'), + call('Content-Disposition', 'attachment; filename="file_3"') + ] + ) + + message.attach.assert_called_with(mime_base.return_value) + assert message.attach.call_count == 3 + + +@patch('orchestrator.activities.email.MIMEBase') +def test_handle_attachments_failure(mime_base, email): + message = MagicMock() + + mime_base.side_effect = Exception("test") + + attachments = [ + { + "filename": "file_1", + "attachment_content": "test_content_1" + } + ] + + email.handle_attachments(attachments, message) + + assert message.attach.call_count == 0 + + +@mark.asyncio +@patch('orchestrator.activities.email.MIMEText') +@patch('orchestrator.activities.email.MIMEMultipart') +async def test_send_email(mimemultipart, mimetext, email): + side_effect_1 = MagicMock() + side_effect_2 = MagicMock() + mimemultipart.side_effect = [ + side_effect_1, + side_effect_2 + ] + + email.email_builder.send_notification = MagicMock() + + email.server.sendmail = MagicMock( + side_effect=[ + None, + Exception("test") + ] + ) + + input_data = { + **metadata, + "receiver_groups": { + "group_1": { + "members": ["test@test.com", "test2@test.com"], + "notifications": [ + { + "attachment_content": "test_content_1", + "trigger": "test_trigger", + "notification_id": "test_notification_id" + } + ], + "html": "test_html1" + }, + "group_2": { + "members": ["test3@test.com", "test4@test.com"], + "notifications": [], + "html": "test_html2" + } + }, + "mail_type": "test_TYPE" + } + + response = await email.send_email(input_data) + + assert response['group_1']['status'] == 'sent' + assert response['group_2']['status'] == 'failed' + + assert mimemultipart.call_count == 2 + + mimetext.assert_has_calls( + [ + call("test_html1", "html"), + call("test_html2", "html") + ] + ) + + side_effect_1.__setitem__.assert_has_calls( + [ + call('From', 'test@test.com'), + call('To', 'test@test.com, test2@test.com'), + call('Subject', 'SIENTIA™ test_TYPE') + ] + ) + + side_effect_2.__setitem__.assert_has_calls( + [ + call('From', 'test@test.com'), + call('To', 'test3@test.com, test4@test.com'), + call('Subject', 'SIENTIA™ test_TYPE') + ] + ) + + email.server.sendmail.assert_has_calls( + [ + call('test@test.com', 'test@test.com, test2@test.com', + side_effect_1.as_string.return_value), + call('test@test.com', 'test3@test.com, test4@test.com', + side_effect_2.as_string.return_value) + ] + ) diff --git a/tests/orchestrator/activities/test_formatters.py b/tests/orchestrator/activities/test_formatters.py index 04ada97..8440e19 100644 --- a/tests/orchestrator/activities/test_formatters.py +++ b/tests/orchestrator/activities/test_formatters.py @@ -1,5 +1,6 @@ from unittest.mock import MagicMock, patch, call, ANY import json +from pandas import DataFrame from pytest import fixture, mark from sientia_do.notifications.models import NotificationLevel from orchestrator.activities.formatters import Formatters @@ -696,3 +697,71 @@ async def test_report_slot_orchestration(formatters): attachment=input_data['deleted_slots'] ) ]) + + +@mark.asyncio +async def test_format_log_report(formatters): + input_data = { + **metadata, + "receiver_groups": { + "test_receiver_group": { + "notifications": [ + { + "notification_id": "test_notification_id", + "trigger": "test_trigger", + "timestamp": "2021-01-01", + "message": "test_message", + "level": "test_level", + "block": "test_block", + "pipeline": "test_pipeline", + "project": "test_project", + "model_name": "test_model_name", + "model_id": "test_model_id" + } + ], + "status": "sent", + }, + "test_receiver_group2": { + "notifications": [ + { + "notification_id": "test_notification_id", + "trigger": "test_trigger", + "timestamp": "2021-01-01", + "message": "test_message", + "level": "test_level", + "block": "test_block", + "pipeline": "test_pipeline", + "project": "test_project", + "model_name": "test_model_name", + "model_id": "test_model_id" + } + ], + "status": "sent", + } + }, + "mail_type": "test_mail_type" + } + + result = await formatters.format_log_report(input_data) + + expected_result = DataFrame( + [ + { + "status": "sent", + "timestamp": "2021-01-01", + "groups": ["test_receiver_group", "test_receiver_group2"], + "message": "test_message", + "level": "test_level", + "notification_id": "test_notification_id", + "block": "test_block", + "schedule": "test_trigger", + "pipeline": "test_pipeline", + "project": "test_project", + "model_name": "test_model_name", + "model_id": "test_model_id", + "mail_type": "test_mail_type" + } + ] + ) + + assert DataFrame(result).equals(expected_result) diff --git a/tests/orchestrator/activities/test_slot_manager.py b/tests/orchestrator/activities/test_slot_manager.py index ad2e9a5..d43112c 100644 --- a/tests/orchestrator/activities/test_slot_manager.py +++ b/tests/orchestrator/activities/test_slot_manager.py @@ -1,8 +1,10 @@ from unittest.mock import MagicMock, patch, call, ANY +from datetime import datetime, timedelta from pandas import DataFrame from pytest import mark, fixture from orchestrator.activities.slot_manager import SlotManager from sientia_do.notifications.models import NotificationLevel +from orchestrator.utils.patterns import DEFAULT_DATE_FORMAT metadata = { "metadata": { @@ -359,3 +361,98 @@ async def test_put_last_data_timestamp_error(slot_manager): else: assert False, "Expected exception" + + +@mark.asyncio +async def test_filter_notification_alerts(slot_manager): + slot_manager.get = MagicMock(side_effect=[ + None, + (datetime.now() - timedelta(seconds=600) + ).strftime(DEFAULT_DATE_FORMAT), + datetime.now().strftime(DEFAULT_DATE_FORMAT), + None, + (datetime.now() - timedelta(seconds=600) + ).strftime(DEFAULT_DATE_FORMAT), + datetime.now().strftime(DEFAULT_DATE_FORMAT)]) + + input_data = { + **metadata, + 'notification_package': [ + { + 'trigger': 'test_trigger_1', + 'notification_id': 'test_notification_id_1' + }, + { + 'trigger': 'test_trigger_2', + 'notification_id': 'test_notification_id_2' + }, + { + 'trigger': 'test_trigger_3', + 'notification_id': 'test_notification_id_3' + } + ], + 'sending_configs': [ + { + 'group_name': 'test_group_1', + 'contents': ['core_alerts', 'persistent_alerts'] + }, + { + 'group_name': 'test_group_2', + 'contents': ['core_alerts'] + } + ], + 'notification_ttl': 300 + } + + response = await slot_manager.filter_notification_alerts(input_data) + + assert response == { + 'test_group_1': { + 'group_name': 'test_group_1', + 'contents': ['core_alerts', 'persistent_alerts'], + 'notifications': [ + { + 'trigger': 'test_trigger_1', + 'notification_id': 'test_notification_id_1' + }, + { + 'trigger': 'test_trigger_2', + 'notification_id': 'test_notification_id_2' + } + ] + }, + 'test_group_2': { + 'group_name': 'test_group_2', + 'contents': ['core_alerts'], + 'notifications': [ + { + 'trigger': 'test_trigger_1', + 'notification_id': 'test_notification_id_1' + } + ] + } + } + + +@mark.asyncio +async def test_store_notification_cache(slot_manager): + """Test store_notification_cache""" + test_data = { + **metadata, + 'log_report': DataFrame({ + 'status': ['sent', 'error'], + 'schedule': ['test_schedule_1', 'test_schedule_2'], + 'notification_id': ['test_notification_id_1', 'test_notification_id_2'] + }).to_dict(), + 'sent_ttl': 600 + } + + slot_manager.set = MagicMock() + + await slot_manager.store_notification_cache(test_data) + + slot_manager.set.assert_called_once_with( + "test_schedule_1:test_notification_id_1", + ANY, + ttl=600 + ) diff --git a/tests/orchestrator/workflows/subworkflows/test_process_notifications.py b/tests/orchestrator/workflows/subworkflows/test_process_notifications.py new file mode 100644 index 0000000..4dd6913 --- /dev/null +++ b/tests/orchestrator/workflows/subworkflows/test_process_notifications.py @@ -0,0 +1,79 @@ +from unittest.mock import AsyncMock, patch, ANY, call +from pytest import fixture, mark +from orchestrator.workflows.subworkflows.process_notifications import ProcessNotifications +from orchestrator.activities.activities import Activities + + +@fixture +def process_notifications(): + return ProcessNotifications() + + +metadata = { + 'metadata': { + 'schedule_name': 'test-schedule-name', + 'workflow_name': 'test-workflow', + 'model_name': '-', + 'model_id': '-', + } +} + + +@mark.asyncio +@patch("orchestrator.workflows.subworkflows.process_notifications.workflow", new_callable=AsyncMock) +async def test_run(workflow_mock, process_notifications): + input_data = { + 'metadata': metadata, + 'notification_package': ["content"], + 'mail_type': 'test_mail_type', + 'schema': 'test_schema', + 'table_name': 'test_table_name', + } + + response = await process_notifications.run(input_data) + + assert response == workflow_mock.execute_local_activity_method.return_value + + workflow_mock.execute_local_activity_method.assert_has_calls([ + call( + Activities.build_email_html, + { + **metadata, + 'receiver_groups': input_data['notification_package'] + }, + schedule_to_close_timeout=ANY, + retry_policy=ANY + ), + call( + Activities.format_log_report, + { + **metadata, + 'receiver_groups': workflow_mock.execute_activity_method.return_value, + 'mail_type': input_data['mail_type'] + }, + ) + ]) + + workflow_mock.execute_activity_method.assert_has_calls([ + call( + Activities.send_email, + { + **metadata, + 'receiver_groups': workflow_mock.execute_local_activity_method.return_value, + 'mail_type': input_data['mail_type'] + }, + schedule_to_close_timeout=ANY, + retry_policy=ANY + ), + call( + Activities.export_data_to_postgres, + { + **metadata, + 'schema': input_data['schema'], + 'table_name': input_data['table_name'], + 'data': workflow_mock.execute_local_activity_method.return_value + }, + schedule_to_close_timeout=ANY, + retry_policy=ANY + ) + ]) diff --git a/tests/orchestrator/workflows/test_alerts.py b/tests/orchestrator/workflows/test_alerts.py new file mode 100644 index 0000000..788e06d --- /dev/null +++ b/tests/orchestrator/workflows/test_alerts.py @@ -0,0 +1,80 @@ +from unittest.mock import AsyncMock, patch, ANY, call +from pytest import fixture, mark +from orchestrator.workflows.alerts import Alerts +from orchestrator.activities.activities import Activities + + +@fixture +def alerts(): + return Alerts() + + +metadata = { + 'metadata': { + 'schedule_name': 'test-schedule-name', + 'workflow_name': 'alerts', + 'model_name': '-', + 'model_id': '-', + } +} + + +@mark.asyncio +@patch("orchestrator.workflows.alerts.workflow", new_callable=AsyncMock) +async def test_run_full_flow(workflow_mock, alerts): + input_data = { + 'schedule_name': 'test-schedule-name', + 'notification_ttl': 300, + 'sent_ttl': 600 + } + + await alerts.run(input_data) + + workflow_mock.execute_child_workflow.assert_has_calls([ + call( + 'load_notification_package', + { + **input_data, + 'metadata': metadata, + 'base_data_filter': { + 'level': 'ERROR' + } + } + ) + ]) + + workflow_mock.execute_child_workflow.assert_has_calls([ + call( + 'process_notifications', + { + 'metadata': metadata, + 'mail_type': 'Alerts', + 'notification_package': workflow_mock.execute_local_activity_method.return_value, + 'schema': 'sientia_data', + 'table_name': 'log_report' + } + ) + ]) + + workflow_mock.execute_local_activity_method.assert_has_calls([ + call( + Activities.filter_notification_alerts, + { + **metadata, + 'notification_package': workflow_mock.execute_child_workflow.return_value['notification_package'], + 'sending_configs': workflow_mock.execute_child_workflow.return_value['sending_configs'], + 'notification_ttl': input_data['notification_ttl'] + } + ) + ]) + + workflow_mock.execute_activity_method.assert_has_calls([ + call( + Activities.store_notification_cache, + { + **metadata, + 'log_report': workflow_mock.execute_child_workflow.return_value, + 'sent_ttl': input_data['sent_ttl'] + } + ) + ]) From 9bc238388a80113af2e4fc3f96d56973adc17b95 Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Mon, 28 Jul 2025 16:12:22 -0300 Subject: [PATCH 04/14] SIENTIAPDE-1172 feat: integrate email configuration and enhance orchestrator activities - Added email configuration parameters to values.yaml for sender details and SMTP settings. - Refactored Email class to correct parameter names and improve logging during initialization. - Introduced build_email_config function to streamline email configuration retrieval. - Updated worker.py to include email and Postgres configurations in the main orchestration process. --- orchestrator/activities/activities.py | 12 ++++----- orchestrator/activities/email.py | 12 +++++---- orchestrator/utils/connectors_config.py | 21 +++++++++++++++ orchestrator/worker/worker.py | 35 +++++++++++++++++++++++-- values.yaml | 16 +++++++++++ 5 files changed, 82 insertions(+), 14 deletions(-) diff --git a/orchestrator/activities/activities.py b/orchestrator/activities/activities.py index 10c1812..9ab17e3 100644 --- a/orchestrator/activities/activities.py +++ b/orchestrator/activities/activities.py @@ -1,14 +1,12 @@ -from temporalio import activity, workflow -from temporalio.client import Client +from temporalio import workflow -from orchestrator.activities.email import Email -from orchestrator.activities.mongo_db import MongoDB with workflow.unsafe.imports_passed_through(): - from orchestrator.activities.couchbase import Couchbase from orchestrator.activities.temporal_manager import TemporalManager from orchestrator.activities.slot_manager import SlotManager from orchestrator.activities.formatters import Formatters + from orchestrator.activities.email import Email + from orchestrator.activities.mongo_db import MongoDB from typing import Any from logging import Logger from sientia_do.temporal.activities.postgres import Postgres @@ -67,8 +65,8 @@ class Activities( # Couchbase, Email.__init__(self, sender_email=email_config['sender_email'], sender_password=email_config['sender_password'], - smpt_server=email_config['smpt_server'], - port=email_config['port'], + smtp_server=email_config['smtp_server'], + smtp_port=email_config['smtp_port'], logger=logger, notification_handler=notification_handler) diff --git a/orchestrator/activities/email.py b/orchestrator/activities/email.py index 4ec30cf..4f95817 100644 --- a/orchestrator/activities/email.py +++ b/orchestrator/activities/email.py @@ -17,20 +17,22 @@ with workflow.unsafe.imports_passed_through(): class Email(BaseActivity): def __init__(self, sender_email: str, sender_password: str, - smpt_server: str, port: int, + smtp_server: str, smtp_port: int, logger: Logger, notification_handler: NotificationHandler): self.email_builder = EmailBuilder(logger=logger) self.sender_email = sender_email self.sender_password = sender_password - self.port = port + self.smtp_port = smtp_port + + logger.info(f"Initializing Email with {smtp_server}:{smtp_port}") + + self.server = smtplib.SMTP(smtp_server, smtp_port) if self.sender_password: - self.server = smtplib.SMTP_SSL(smpt_server, port) + self.server.starttls() self.server.login(self.sender_email, self.sender_password) - else: - self.server = smtplib.SMTP(smpt_server, port) BaseActivity.__init__(self, logger=logger, diff --git a/orchestrator/utils/connectors_config.py b/orchestrator/utils/connectors_config.py index 519731a..7237a05 100644 --- a/orchestrator/utils/connectors_config.py +++ b/orchestrator/utils/connectors_config.py @@ -38,3 +38,24 @@ def build_temporal_config(): 'temporal_scouter_namespace': getenv('TEMPORAL_SCOUTER_NAMESPACE', 'scouter'), 'temporal_laborious_namespace': getenv('TEMPORAL_LABORIOUS_NAMESPACE', 'laborious') } + + +def build_postgres_config(): + return { + 'host': getenv('POSTGRES_HOST', 'localhost'), + 'port': int(getenv('POSTGRES_PORT', '5432')), + 'user': getenv('POSTGRES_USER', 'sientia'), + 'password': getenv('POSTGRES_PASSWORD', 'sientia'), + 'dbname': getenv('POSTGRES_DBNAME', 'sientia'), + 'min_connections': int(getenv('POSTGRES_MIN_CONNECTIONS', '5')), + 'max_connections': int(getenv('POSTGRES_MAX_CONNECTIONS', '20')) + } + + +def build_email_config(): + return { + 'sender_email': getenv('EMAIL_SENDER', 'sientia-alerts@aignosi.com'), + 'sender_password': getenv('EMAIL_SENDER_PASSWORD', 'sientia'), + 'smtp_server': getenv('EMAIL_SMTP_SERVER', 'smtp.gmail.com'), + 'smtp_port': int(getenv('EMAIL_SMTP_PORT', '587')) + } diff --git a/orchestrator/worker/worker.py b/orchestrator/worker/worker.py index 525a376..21f9e96 100644 --- a/orchestrator/worker/worker.py +++ b/orchestrator/worker/worker.py @@ -1,18 +1,23 @@ from temporalio import workflow, client from temporalio.worker import Worker -from orchestrator.utils.connectors_config import build_temporal_config with workflow.unsafe.imports_passed_through(): import os import sys import asyncio + from orchestrator.workflows.alerts import Alerts + from orchestrator.workflows.subworkflows.load_notification_package import LoadNotificationPackage + from orchestrator.workflows.subworkflows.process_notifications import ProcessNotifications from orchestrator.workflows.orchestrator import Orchestrator from orchestrator.activities.activities import Activities from orchestrator.utils.connectors_config import ( # build_couchbase_config, build_redis_config, - build_mongodb_config + build_mongodb_config, + build_temporal_config, + build_email_config, + build_postgres_config ) from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler from sientia_do.temporal.utils.logger import get_logger @@ -48,6 +53,8 @@ async def main(): temporal_config=build_temporal_config(), redis_config=build_redis_config(), mongodb_config=build_mongodb_config(), + email_config=build_email_config(), + postgres_config=build_postgres_config(), logger=logger, notification_handler=notification_handler ) @@ -90,6 +97,30 @@ async def main(): activities.report_slot_orchestration, activities.format_schedule_config, ] + ), + Worker( + temporal_client, + task_queue='alerts-queue', + workflows=[Alerts, LoadNotificationPackage, ProcessNotifications], + activities=[ + # Load notifications + activities.get_last_data_timestamp, + activities.find_documents_in_mongodb, + activities.load_latest_data, + activities.put_last_data_timestamp, + + # Format and filter notifications + activities.filter_notification_alerts, + + # Send email and export data to postgres + activities.build_email_html, + activities.send_email, + activities.format_log_report, + activities.export_data_to_postgres, + + # Store notification cache + activities.store_notification_cache + ] ) ] diff --git a/values.yaml b/values.yaml index 4614016..05b3754 100644 --- a/values.yaml +++ b/values.yaml @@ -170,6 +170,18 @@ env: - name: MONGODB_TTL_INDEX_HOURS value: "1" + - name: EMAIL_SENDER + value: "sientia-alerts@aignosi.com" + - name: EMAIL_SENDER_PASSWORD + valueFrom: + secretKeyRef: + name: smtp-credentials + key: sender-password + - name: EMAIL_SMTP_SERVER + value: "smtp.gmail.com" + - name: EMAIL_SMTP_PORT + value: "587" + - name: KAFKA_BOOTSTRAP_SERVERS value: "kafka.kafka.svc.cluster.local:9092" @@ -201,3 +213,7 @@ ssh: # --namespace sientia \ # --from-file=ssh-privatekey=git_key \ # --type=kubernetes.io/ssh-auth + +# kubectl create secret generic smtp-credentials \ +# --namespace sientia \ +# --from-literal=app_password='sua-senha-de-app-de-16-digitos' \ No newline at end of file From c3ba2e4b5adbaf7525eff547a6e0ae399eedb1f6 Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Mon, 28 Jul 2025 17:26:28 -0300 Subject: [PATCH 05/14] SIENTIAPDE-1172 refactor: update email HTML structure and enhance notification generator - Modified email HTML to reflect updated model names and error/warning messages. - Adjusted notification generator to improve execution counts and streamline output handling. - Refactored activities in orchestrator to correct parameter names and improve type hints for better clarity. - Enhanced email builder to handle error, warning, and info models more efficiently. --- email.html | 98 ++++---- notification_generator.ipynb | 214 +++++++++--------- orchestrator/activities/activities.py | 4 +- orchestrator/activities/email.py | 2 +- orchestrator/activities/slot_manager.py | 4 +- orchestrator/utils/email_builder.py | 10 +- orchestrator/workflows/alerts.py | 10 +- .../subworkflows/load_notification_package.py | 9 + .../subworkflows/process_notifications.py | 5 +- 9 files changed, 184 insertions(+), 172 deletions(-) diff --git a/email.html b/email.html index 7f53648..4207ca5 100644 --- a/email.html +++ b/email.html @@ -17,7 +17,7 @@

Errors detected:

-

Model: ut aliqua ut ipsum

+

Model: do elit

@@ -30,16 +30,16 @@ - - - - + + + +
TAG_node5:tag_87_LISTENNING_STOPPEDdolor_elit_aliqua2025-07-25 16:50:48.925421+00:00sit elit ipsum labore incididunt magna do et sit incididuntREPORT_PARTITION_MANAGERlorem_adipiscing2025-07-28 20:05:38.650661+00:00incididunt dolor lorem incididunt elit incididunt dolor consectetur ipsum ut
-

Model: elit dolore

+

Model: dolor magna

@@ -52,16 +52,16 @@ - - - - + + + +
OPC_LISTENNING_BACK__server_10magna_magna_et2025-07-25 16:50:48.925478+00:00aliqua eiusmod ut eiusmod ut et ipsumREPORT_PARTITION_MANAGERtempor_dolore_eiusmod2025-07-28 20:05:38.650910+00:00ipsum amet ipsum aliqua et eiusmod
-

Model: lorem lorem ipsum amet

+

Model: labore sit lorem

@@ -74,10 +74,10 @@ - - - - + + + + @@ -85,7 +85,7 @@

Warnings detected:

-

Model: amet eiusmod dolor ipsum

+

Model: incididunt adipiscing elit sit

TAG_node8:tag_11_LISTENNING_STOPPEDdo_sit2025-07-25 16:50:48.925506+00:00labore do adipiscing adipiscing incididunt eiusmodOPC_CONNECTION_RETRY__server_6dolor_labore_lorem2025-07-28 20:05:38.650949+00:00et adipiscing ipsum ut aliqua ut adipiscing aliqua amet
@@ -98,16 +98,16 @@ - - - - + + + +
REPORT_PARTITION_MANAGERadipiscing_amet_ut_et2025-07-25 16:50:48.925368+00:00eiusmod et amet sit lorem incididuntADIPISCING_AMET_IPSUMeiusmod_aliqua_incididunt2025-07-28 20:05:38.650621+00:00magna incididunt dolore sed eiusmod sit incididunt ut
-

Model: sit et labore

+

Model: do adipiscing et

@@ -120,16 +120,16 @@ - - - - + + + +
SIT_CONSECTETUR_EIUSMODadipiscing_elit_ut2025-07-25 16:50:48.925392+00:00magna do eiusmod et labore elitTAG_node46:tag_73_LISTENNING_STOPPEDamet_dolor2025-07-28 20:05:38.650706+00:00dolore do et consectetur adipiscing do magna eiusmod eiusmod
-

Model: consectetur labore do consectetur

+

Model: lorem dolore amet

@@ -142,16 +142,16 @@ - - - - + + + +
SED_SIT_DOLOR_CONSECTETURdolor_dolore_ut2025-07-25 16:50:48.925435+00:00magna sed incididunt dolor temporTAG_node13:tag_59_LISTENNING_STOPPEDipsum_tempor_ipsum2025-07-28 20:05:38.650785+00:00et sed adipiscing tempor dolor magna do lorem
-

Model: et sed ut et

+

Model: eiusmod sed

@@ -164,10 +164,10 @@ - - - - + + + + @@ -175,7 +175,7 @@

Infos detected:

-

Model: eiusmod eiusmod

+

Model: et sed

UT_UT_LABOREsit_do2025-07-25 16:50:48.925492+00:00do sed sed elit do eiusmodOPC_SUBSCRIPTION__server_2do_ipsum2025-07-28 20:05:38.650826+00:00eiusmod et consectetur do dolore dolor sed amet
@@ -188,16 +188,16 @@ - - - - + + + +
OPC_CONNECTION_RETRY__server_2labore_sit_do2025-07-25 16:50:48.925407+00:00magna et ipsum amet dolore adipiscing adipiscing doOPC_LISTENNING_STOPPED__server_6labore_do_ipsum_ipsum2025-07-28 20:05:38.650570+00:00tempor dolore eiusmod sit sit sed elit
-

Model: adipiscing consectetur

+

Model: dolore sit

@@ -211,15 +211,15 @@ - - - + + +
REPORT_PARTITION_MANAGERincididunt_aliqua_ipsum_ut2025-07-25 16:50:48.925447+00:00labore magna adipiscing tempor elit et et magna elit loremet_lorem_incididunt_ipsum2025-07-28 20:05:38.650742+00:00adipiscing magna adipiscing adipiscing sit tempor
-

Model: et amet

+

Model: adipiscing do

@@ -232,10 +232,10 @@ - - - - + + + + diff --git a/notification_generator.ipynb b/notification_generator.ipynb index 197e89b..19a64c4 100644 --- a/notification_generator.ipynb +++ b/notification_generator.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 2, + "execution_count": 13, "id": "1786cbf0", "metadata": {}, "outputs": [ @@ -86,18 +86,10 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 14, "id": "c1467b72", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'WARNING': {'section_name': 'Warnings detected:', 'models': {'amet eiusmod dolor ipsum': {'model_name': 'amet eiusmod dolor ipsum', 'events': [{'project': 'sientia-laborious', 'pipeline': 'ut_eiusmod_do', 'trigger': 'aliqua_magna_labore_magna_do_ut', 'model_name': 'amet eiusmod dolor ipsum', 'model_id': '26', 'block': 'adipiscing_amet_ut_et', 'level': 'WARNING', 'message': 'eiusmod et amet sit lorem incididunt', 'attachment_content': 'sed et ipsum aliqua et consectetur incididunt aliqua incididunt aliqua eiusmod adipiscing incididunt sed et ut ipsum aliqua elit ut', 'notification_id': 'REPORT_PARTITION_MANAGER', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925368, tzinfo=datetime.timezone.utc)}]}, 'sit et labore': {'model_name': 'sit et labore', 'events': [{'project': 'sientia-laborious', 'pipeline': 'tempor_eiusmod_amet_amet', 'trigger': 'labore_elit_adipiscing_adipiscing', 'model_name': 'sit et labore', 'model_id': '8', 'block': 'adipiscing_elit_ut', 'level': 'WARNING', 'message': 'magna do eiusmod et labore elit', 'attachment_content': 'incididunt incididunt do adipiscing consectetur adipiscing ipsum incididunt et sed sed magna dolore ut ipsum amet dolor sed tempor lorem incididunt do labore elit aliqua magna eiusmod dolor', 'notification_id': 'SIT_CONSECTETUR_EIUSMOD', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925392, tzinfo=datetime.timezone.utc)}]}, 'consectetur labore do consectetur': {'model_name': 'consectetur labore do consectetur', 'events': [{'project': 'sientia-laborious', 'pipeline': 'et_consectetur_ut', 'trigger': 'labore_aliqua_tempor_amet', 'model_name': 'consectetur labore do consectetur', 'model_id': '50', 'block': 'dolor_dolore_ut', 'level': 'WARNING', 'message': 'magna sed incididunt dolor tempor', 'attachment_content': 'lorem labore tempor amet adipiscing lorem dolor aliqua do et incididunt adipiscing labore aliqua dolor elit aliqua eiusmod sed aliqua aliqua consectetur incididunt sed ipsum eiusmod labore do', 'notification_id': 'SED_SIT_DOLOR_CONSECTETUR', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925435, tzinfo=datetime.timezone.utc)}]}, 'et sed ut et': {'model_name': 'et sed ut et', 'events': [{'project': 'sientia-laborious', 'pipeline': 'eiusmod_consectetur_eiusmod_dolor', 'trigger': 'aliqua_sit_eiusmod', 'model_name': 'et sed ut et', 'model_id': '60', 'block': 'sit_do', 'level': 'WARNING', 'message': 'do sed sed elit do eiusmod', 'attachment_content': 'sit labore magna labore lorem eiusmod aliqua magna et eiusmod magna labore et amet adipiscing consectetur sit ut dolore tempor tempor amet ut', 'notification_id': 'UT_UT_LABORE', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925492, tzinfo=datetime.timezone.utc)}]}}}, 'INFO': {'section_name': 'Infos detected:', 'models': {'eiusmod eiusmod': {'model_name': 'eiusmod eiusmod', 'events': [{'project': 'sientia-laborious', 'pipeline': 'do_et_et_dolor', 'trigger': 'adipiscing_dolor_dolore_adipiscing_tempor', 'model_name': 'eiusmod eiusmod', 'model_id': '70', 'block': 'labore_sit_do', 'level': 'INFO', 'message': 'magna et ipsum amet dolore adipiscing adipiscing do', 'attachment_content': 'aliqua amet lorem amet elit tempor amet aliqua do aliqua ut dolore sed eiusmod consectetur tempor dolor sit magna eiusmod consectetur ipsum', 'notification_id': 'OPC_CONNECTION_RETRY__server_2', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925407, tzinfo=datetime.timezone.utc)}]}, 'adipiscing consectetur': {'model_name': 'adipiscing consectetur', 'events': [{'project': 'sientia-laborious', 'pipeline': 'sit_do_aliqua', 'trigger': 'aliqua_amet_magna_ipsum_lorem', 'model_name': 'adipiscing consectetur', 'model_id': '48', 'block': 'incididunt_aliqua_ipsum_ut', 'level': 'INFO', 'message': 'labore magna adipiscing tempor elit et et magna elit lorem', 'attachment_content': 'magna labore dolor ipsum dolor lorem sed magna eiusmod ipsum do aliqua magna labore et elit consectetur et adipiscing lorem incididunt lorem', 'notification_id': 'REPORT_PARTITION_MANAGER', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925447, tzinfo=datetime.timezone.utc)}]}, 'et amet': {'model_name': 'et amet', 'events': [{'project': 'sientia-laborious', 'pipeline': 'consectetur_labore_magna', 'trigger': 'eiusmod_incididunt_consectetur_tempor', 'model_name': 'et amet', 'model_id': '31', 'block': 'sed_elit_ut_do', 'level': 'INFO', 'message': 'dolor et adipiscing adipiscing dolore ipsum ipsum aliqua et elit', 'attachment_content': 'tempor dolore ipsum ut ut et dolor eiusmod do magna magna magna et tempor labore labore dolor labore eiusmod do dolor incididunt lorem dolore ipsum ipsum aliqua sed incididunt', 'notification_id': 'OPC_LISTENNING_BACK__server_2', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925462, tzinfo=datetime.timezone.utc)}]}}}, 'ERROR': {'section_name': 'Errors detected:', 'models': {'ut aliqua ut ipsum': {'model_name': 'ut aliqua ut ipsum', 'events': [{'project': 'sientia-laborious', 'pipeline': 'sit_amet', 'trigger': 'aliqua_sit_consectetur_lorem_consectetur', 'model_name': 'ut aliqua ut ipsum', 'model_id': '80', 'block': 'dolor_elit_aliqua', 'level': 'ERROR', 'message': 'sit elit ipsum labore incididunt magna do et sit incididunt', 'attachment_content': 'amet ut et ut sed ipsum aliqua elit incididunt sed dolore dolore ipsum sed elit sit adipiscing incididunt elit labore do magna et consectetur aliqua', 'notification_id': 'TAG_node5:tag_87_LISTENNING_STOPPED', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925421, tzinfo=datetime.timezone.utc)}]}, 'elit dolore': {'model_name': 'elit dolore', 'events': [{'project': 'sientia-laborious', 'pipeline': 'sed_et_tempor_labore', 'trigger': 'aliqua_tempor_sit_lorem_amet_et', 'model_name': 'elit dolore', 'model_id': '20', 'block': 'magna_magna_et', 'level': 'ERROR', 'message': 'aliqua eiusmod ut eiusmod ut et ipsum', 'attachment_content': 'labore do dolore dolore adipiscing amet tempor eiusmod labore elit consectetur incididunt dolor et elit eiusmod ut ut aliqua incididunt', 'notification_id': 'OPC_LISTENNING_BACK__server_10', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925478, tzinfo=datetime.timezone.utc)}]}, 'lorem lorem ipsum amet': {'model_name': 'lorem lorem ipsum amet', 'events': [{'project': 'sientia-laborious', 'pipeline': 'amet_eiusmod_eiusmod', 'trigger': 'magna_aliqua_dolore_incididunt_do_do', 'model_name': 'lorem lorem ipsum amet', 'model_id': '47', 'block': 'do_sit', 'level': 'ERROR', 'message': 'labore do adipiscing adipiscing incididunt eiusmod', 'attachment_content': 'sit amet dolor magna et sit adipiscing sed consectetur labore sit ut adipiscing magna amet dolor lorem dolore eiusmod sed dolor dolor sit consectetur sit aliqua dolore tempor', 'notification_id': 'TAG_node8:tag_11_LISTENNING_STOPPED', 'timestamp': datetime.datetime(2025, 7, 25, 16, 50, 48, 925506, tzinfo=datetime.timezone.utc)}]}}}}\n" - ] - } - ], + "outputs": [], "source": [ "from orchestrator.utils.email_builder import EmailBuilder\n", "from unittest.mock import MagicMock\n", @@ -113,7 +105,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 15, "id": "ffd7be77", "metadata": {}, "outputs": [ @@ -121,118 +113,118 @@ "data": { "text/plain": [ "[{'project': 'sientia-laborious',\n", - " 'pipeline': 'lorem_sed_ut',\n", - " 'trigger': 'dolore_elit_lorem',\n", - " 'model_name': 'lorem tempor',\n", - " 'model_id': '71',\n", - " 'block': 'aliqua_aliqua_et',\n", - " 'level': 'ERROR',\n", - " 'message': 'tempor eiusmod dolor eiusmod sed amet labore et magna',\n", - " 'attachment_content': 'sed amet incididunt et amet lorem lorem ut adipiscing magna incididunt sit aliqua ut dolore dolore ipsum aliqua amet labore dolor amet et elit dolore dolor consectetur',\n", - " 'notification_id': 'OPC_LISTENNING_BACK__server_1',\n", - " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147390, tzinfo=datetime.timezone.utc)},\n", - " {'project': 'sientia-laborious',\n", - " 'pipeline': 'et_do_eiusmod_adipiscing',\n", - " 'trigger': 'consectetur_magna_elit',\n", - " 'model_name': 'elit incididunt elit',\n", - " 'model_id': '85',\n", - " 'block': 'amet_elit_adipiscing',\n", - " 'level': 'WARNING',\n", - " 'message': 'do labore consectetur lorem lorem sit do aliqua magna magna',\n", - " 'attachment_content': 'aliqua tempor aliqua adipiscing sit elit ut magna sit ipsum magna tempor do incididunt sit magna sit consectetur amet elit lorem ipsum incididunt ipsum',\n", - " 'notification_id': 'ADIPISCING_DO_DO_DOLOR',\n", - " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147411, tzinfo=datetime.timezone.utc)},\n", - " {'project': 'sientia-laborious',\n", - " 'pipeline': 'magna_tempor',\n", - " 'trigger': 'et_elit_labore_ut_eiusmod',\n", - " 'model_name': 'sit ipsum et eiusmod',\n", - " 'model_id': '14',\n", - " 'block': 'incididunt_do',\n", + " 'pipeline': 'magna_incididunt',\n", + " 'trigger': 'dolor_dolor_aliqua_ipsum_do_magna',\n", + " 'model_name': 'et sed',\n", + " 'model_id': '53',\n", + " 'block': 'labore_do_ipsum_ipsum',\n", " 'level': 'INFO',\n", - " 'message': 'sit elit eiusmod consectetur magna labore',\n", - " 'attachment_content': 'amet sit et do dolore eiusmod incididunt sed dolor dolor ipsum tempor lorem dolore sed amet ipsum ut lorem aliqua',\n", - " 'notification_id': 'IPSUM_ADIPISCING_IPSUM_MAGNA_LABORE',\n", - " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147426, tzinfo=datetime.timezone.utc)},\n", + " 'message': 'tempor dolore eiusmod sit sit sed elit',\n", + " 'attachment_content': 'sit labore sed amet sit magna ut ut labore do ut adipiscing amet incididunt magna do sit eiusmod dolor do sit incididunt amet incididunt consectetur elit et ipsum elit amet',\n", + " 'notification_id': 'OPC_LISTENNING_STOPPED__server_6',\n", + " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650570, tzinfo=datetime.timezone.utc)},\n", " {'project': 'sientia-laborious',\n", - " 'pipeline': 'adipiscing_adipiscing_adipiscing',\n", - " 'trigger': 'elit_dolor_sit_et',\n", - " 'model_name': 'sit adipiscing tempor sed',\n", - " 'model_id': '46',\n", - " 'block': 'tempor_tempor_sit',\n", + " 'pipeline': 'adipiscing_do',\n", + " 'trigger': 'sit_lorem_dolore',\n", + " 'model_name': 'incididunt adipiscing elit sit',\n", + " 'model_id': '64',\n", + " 'block': 'eiusmod_aliqua_incididunt',\n", " 'level': 'WARNING',\n", - " 'message': 'tempor adipiscing tempor labore dolore tempor elit',\n", - " 'attachment_content': 'elit sit dolore consectetur ut ipsum ipsum aliqua incididunt elit eiusmod labore sed eiusmod eiusmod aliqua eiusmod eiusmod labore magna incididunt consectetur ipsum et aliqua sit adipiscing labore do',\n", - " 'notification_id': 'TAG_node57:tag_97_LISTENNING_STOPPED',\n", - " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147440, tzinfo=datetime.timezone.utc)},\n", + " 'message': 'magna incididunt dolore sed eiusmod sit incididunt ut',\n", + " 'attachment_content': 'incididunt incididunt adipiscing do ipsum tempor ut eiusmod sit tempor tempor aliqua aliqua dolor labore incididunt amet do sed tempor dolor labore',\n", + " 'notification_id': 'ADIPISCING_AMET_IPSUM',\n", + " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650621, tzinfo=datetime.timezone.utc)},\n", " {'project': 'sientia-laborious',\n", - " 'pipeline': 'eiusmod_amet_aliqua',\n", - " 'trigger': 'ipsum_lorem_et',\n", - " 'model_name': 'consectetur adipiscing',\n", - " 'model_id': '72',\n", - " 'block': 'tempor_et_adipiscing_sed',\n", + " 'pipeline': 'sed_aliqua',\n", + " 'trigger': 'aliqua_adipiscing_adipiscing_sed_do_sit',\n", + " 'model_name': 'do elit',\n", + " 'model_id': '73',\n", + " 'block': 'lorem_adipiscing',\n", " 'level': 'ERROR',\n", - " 'message': 'lorem ipsum do amet elit eiusmod elit lorem',\n", - " 'attachment_content': 'ipsum ut dolor eiusmod dolore elit consectetur ipsum dolor do sed dolor aliqua sit consectetur aliqua sit sit consectetur et incididunt adipiscing lorem do adipiscing',\n", + " 'message': 'incididunt dolor lorem incididunt elit incididunt dolor consectetur ipsum ut',\n", + " 'attachment_content': 'dolor ut do amet dolore dolor do eiusmod eiusmod incididunt aliqua et sed labore incididunt aliqua sed et tempor consectetur elit et consectetur do ipsum',\n", " 'notification_id': 'REPORT_PARTITION_MANAGER',\n", - " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147453, tzinfo=datetime.timezone.utc)},\n", + " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650661, tzinfo=datetime.timezone.utc)},\n", " {'project': 'sientia-laborious',\n", - " 'pipeline': 'eiusmod_et_incididunt',\n", - " 'trigger': 'dolore_do_lorem_aliqua',\n", - " 'model_name': 'aliqua sed tempor ipsum',\n", - " 'model_id': '2',\n", - " 'block': 'et_elit_amet',\n", + " 'pipeline': 'dolore_et_aliqua_et',\n", + " 'trigger': 'adipiscing_amet_sit_do',\n", + " 'model_name': 'do adipiscing et',\n", + " 'model_id': '10',\n", + " 'block': 'amet_dolor',\n", " 'level': 'WARNING',\n", - " 'message': 'consectetur aliqua dolore amet eiusmod aliqua',\n", - " 'attachment_content': 'sit amet amet aliqua incididunt aliqua amet ipsum magna do adipiscing aliqua tempor magna dolor dolor labore et lorem consectetur dolore do ut adipiscing dolore',\n", - " 'notification_id': 'AMET_ELIT_ELIT_MAGNA_ALIQUA',\n", - " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147482, tzinfo=datetime.timezone.utc)},\n", + " 'message': 'dolore do et consectetur adipiscing do magna eiusmod eiusmod',\n", + " 'attachment_content': 'sit adipiscing adipiscing consectetur magna sed magna adipiscing dolor incididunt et adipiscing adipiscing elit et labore tempor tempor adipiscing sit dolore sed dolore dolor labore sed',\n", + " 'notification_id': 'TAG_node46:tag_73_LISTENNING_STOPPED',\n", + " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650706, tzinfo=datetime.timezone.utc)},\n", " {'project': 'sientia-laborious',\n", - " 'pipeline': 'amet_dolore',\n", - " 'trigger': 'labore_magna_labore',\n", - " 'model_name': 'dolor ut incididunt adipiscing',\n", - " 'model_id': '3',\n", - " 'block': 'dolor_sit',\n", - " 'level': 'WARNING',\n", - " 'message': 'ut ipsum sed consectetur eiusmod',\n", - " 'attachment_content': 'aliqua dolor magna magna sed et magna sit aliqua labore aliqua sed ipsum aliqua amet dolore ipsum aliqua consectetur adipiscing eiusmod',\n", + " 'pipeline': 'ipsum_lorem_sit_sit',\n", + " 'trigger': 'et_ut_sit_dolore',\n", + " 'model_name': 'dolore sit',\n", + " 'model_id': '58',\n", + " 'block': 'et_lorem_incididunt_ipsum',\n", + " 'level': 'INFO',\n", + " 'message': 'adipiscing magna adipiscing adipiscing sit tempor',\n", + " 'attachment_content': 'incididunt amet dolor incididunt dolore et lorem amet ut magna tempor amet do consectetur dolore magna elit aliqua eiusmod ipsum labore',\n", " 'notification_id': 'REPORT_PARTITION_MANAGER',\n", - " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147494, tzinfo=datetime.timezone.utc)},\n", + " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650742, tzinfo=datetime.timezone.utc)},\n", " {'project': 'sientia-laborious',\n", - " 'pipeline': 'dolor_ipsum',\n", - " 'trigger': 'do_sed_et_dolore_elit',\n", - " 'model_name': 'adipiscing aliqua labore magna',\n", + " 'pipeline': 'dolor_sit_aliqua',\n", + " 'trigger': 'lorem_incididunt_labore',\n", + " 'model_name': 'lorem dolore amet',\n", + " 'model_id': '13',\n", + " 'block': 'ipsum_tempor_ipsum',\n", + " 'level': 'WARNING',\n", + " 'message': 'et sed adipiscing tempor dolor magna do lorem',\n", + " 'attachment_content': 'do incididunt dolore eiusmod adipiscing amet amet amet eiusmod lorem dolor labore labore ipsum dolore amet labore ipsum labore do amet',\n", + " 'notification_id': 'TAG_node13:tag_59_LISTENNING_STOPPED',\n", + " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650785, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'lorem_do_consectetur',\n", + " 'trigger': 'sit_aliqua_sit',\n", + " 'model_name': 'eiusmod sed',\n", + " 'model_id': '9',\n", + " 'block': 'do_ipsum',\n", + " 'level': 'WARNING',\n", + " 'message': 'eiusmod et consectetur do dolore dolor sed amet',\n", + " 'attachment_content': 'sed dolore dolore et et dolore dolor eiusmod aliqua adipiscing incididunt dolor lorem tempor consectetur dolor amet elit et aliqua lorem ipsum consectetur tempor tempor elit labore labore dolore',\n", + " 'notification_id': 'OPC_SUBSCRIPTION__server_2',\n", + " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650826, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'labore_elit_adipiscing',\n", + " 'trigger': 'dolore_incididunt_tempor_lorem_dolore',\n", + " 'model_name': 'adipiscing do',\n", + " 'model_id': '63',\n", + " 'block': 'tempor_elit',\n", + " 'level': 'INFO',\n", + " 'message': 'et do aliqua lorem incididunt',\n", + " 'attachment_content': 'ut eiusmod consectetur sit labore ut tempor et sit sit do incididunt incididunt sed eiusmod dolor adipiscing dolor ut sed amet labore amet amet sed et elit sed ipsum',\n", + " 'notification_id': 'TAG_node76:tag_73_LISTENNING_STOPPED',\n", + " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650875, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'ut_lorem_et',\n", + " 'trigger': 'labore_labore_ipsum_magna',\n", + " 'model_name': 'dolor magna',\n", " 'model_id': '74',\n", - " 'block': 'incididunt_dolore',\n", - " 'level': 'INFO',\n", - " 'message': 'amet do amet sed sit',\n", - " 'attachment_content': 'et sed labore adipiscing labore dolore amet tempor incididunt aliqua dolor labore tempor adipiscing incididunt consectetur magna tempor tempor aliqua ut sit magna dolore ipsum',\n", - " 'notification_id': 'REPORT_PARTITION_MANAGER',\n", - " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147510, tzinfo=datetime.timezone.utc)},\n", - " {'project': 'sientia-laborious',\n", - " 'pipeline': 'magna_sed_ut_sit',\n", - " 'trigger': 'ut_dolore_consectetur_labore_incididunt',\n", - " 'model_name': 'aliqua et consectetur',\n", - " 'model_id': '15',\n", - " 'block': 'ipsum_et',\n", + " 'block': 'tempor_dolore_eiusmod',\n", " 'level': 'ERROR',\n", - " 'message': 'ut dolore ipsum magna tempor sed magna labore amet magna',\n", - " 'attachment_content': 'ipsum adipiscing et amet elit dolor ut aliqua consectetur ut do elit amet tempor incididunt eiusmod eiusmod dolore ipsum sit dolore adipiscing aliqua elit adipiscing labore sed eiusmod ut',\n", - " 'notification_id': 'LABORE_INCIDIDUNT_ADIPISCING_DO_LABORE',\n", - " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147525, tzinfo=datetime.timezone.utc)},\n", + " 'message': 'ipsum amet ipsum aliqua et eiusmod',\n", + " 'attachment_content': 'incididunt dolore do sit sed ut sed sit magna sed amet elit elit consectetur dolor labore magna consectetur tempor magna',\n", + " 'notification_id': 'REPORT_PARTITION_MANAGER',\n", + " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650910, tzinfo=datetime.timezone.utc)},\n", " {'project': 'sientia-laborious',\n", - " 'pipeline': 'lorem_adipiscing_lorem_incididunt',\n", - " 'trigger': 'adipiscing_magna_dolor_consectetur_elit',\n", - " 'model_name': 'dolor sit lorem',\n", - " 'model_id': '51',\n", - " 'block': 'dolor_ipsum',\n", - " 'level': 'INFO',\n", - " 'message': 'labore aliqua dolor eiusmod incididunt adipiscing tempor',\n", - " 'attachment_content': 'lorem magna dolore consectetur eiusmod sit dolor eiusmod ut do dolor labore eiusmod et dolore sit tempor consectetur ipsum adipiscing eiusmod dolore consectetur labore sit do',\n", - " 'notification_id': 'CONSECTETUR_MAGNA_AMET_LOREM_TEMPOR',\n", - " 'timestamp': datetime.datetime(2025, 7, 25, 15, 44, 24, 147539, tzinfo=datetime.timezone.utc)}]" + " 'pipeline': 'magna_sit_amet_sed',\n", + " 'trigger': 'ut_dolor_sed',\n", + " 'model_name': 'labore sit lorem',\n", + " 'model_id': '13',\n", + " 'block': 'dolor_labore_lorem',\n", + " 'level': 'ERROR',\n", + " 'message': 'et adipiscing ipsum ut aliqua ut adipiscing aliqua amet',\n", + " 'attachment_content': 'aliqua labore aliqua lorem labore aliqua sed sit magna ut do amet do eiusmod sit consectetur sed ipsum elit amet aliqua labore sit',\n", + " 'notification_id': 'OPC_CONNECTION_RETRY__server_6',\n", + " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650949, tzinfo=datetime.timezone.utc)}]" ] }, - "execution_count": 5, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -243,17 +235,17 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 16, "id": "55aed216", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "InsertManyResult([ObjectId('68839c5572e2953521d60ada'), ObjectId('68839c5572e2953521d60adb'), ObjectId('68839c5572e2953521d60adc'), ObjectId('68839c5572e2953521d60add'), ObjectId('68839c5572e2953521d60ade'), ObjectId('68839c5572e2953521d60adf'), ObjectId('68839c5572e2953521d60ae0'), ObjectId('68839c5572e2953521d60ae1'), ObjectId('68839c5572e2953521d60ae2'), ObjectId('68839c5572e2953521d60ae3')], acknowledged=True)" + "InsertManyResult([ObjectId('6887d812645b7515e8402983'), ObjectId('6887d812645b7515e8402984'), ObjectId('6887d812645b7515e8402985'), ObjectId('6887d812645b7515e8402986'), ObjectId('6887d812645b7515e8402987'), ObjectId('6887d812645b7515e8402988'), ObjectId('6887d812645b7515e8402989'), ObjectId('6887d812645b7515e840298a'), ObjectId('6887d812645b7515e840298b'), ObjectId('6887d812645b7515e840298c')], acknowledged=True)" ] }, - "execution_count": 6, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } diff --git a/orchestrator/activities/activities.py b/orchestrator/activities/activities.py index 9ab17e3..2bf0388 100644 --- a/orchestrator/activities/activities.py +++ b/orchestrator/activities/activities.py @@ -73,9 +73,9 @@ class Activities( # Couchbase, Postgres.__init__(self, host=postgres_config['host'], port=postgres_config['port'], - user=postgres_config['username'], + user=postgres_config['user'], password=postgres_config['password'], - dbname=postgres_config['database_name'], + dbname=postgres_config['dbname'], min_connections=postgres_config['min_connections'], max_connections=postgres_config['max_connections'], logger=logger, diff --git a/orchestrator/activities/email.py b/orchestrator/activities/email.py index 4f95817..b21321d 100644 --- a/orchestrator/activities/email.py +++ b/orchestrator/activities/email.py @@ -39,7 +39,7 @@ class Email(BaseActivity): notification_handler=notification_handler) @activity.defn(name="build_email_html") - async def build_email_html(self, input_data: dict[str, Any]) -> str: + async def build_email_html(self, input_data: dict[str, Any]) -> dict[str, Any]: """ Builds the email html for each receiver group. input_data: diff --git a/orchestrator/activities/slot_manager.py b/orchestrator/activities/slot_manager.py index eb3554f..40ef29a 100644 --- a/orchestrator/activities/slot_manager.py +++ b/orchestrator/activities/slot_manager.py @@ -318,7 +318,7 @@ class SlotManager(Redis): return receiver_groups @activity.defn(name="store_notification_cache") - async def store_notification_cache(self, input_data: dict[str, Any]) -> dict[str, Any]: + async def store_notification_cache(self, input_data: dict[str, Any]) -> None: """ Store notification cache """ @@ -335,5 +335,3 @@ class SlotManager(Redis): if status == 'sent': key = f"{row['schedule']}:{row['notification_id']}" self.set(key, now, ttl=sent_ttl) - - return log_report diff --git a/orchestrator/utils/email_builder.py b/orchestrator/utils/email_builder.py index 0aa2881..e01d580 100644 --- a/orchestrator/utils/email_builder.py +++ b/orchestrator/utils/email_builder.py @@ -24,14 +24,18 @@ class EmailBuilder: return template.render(parameters) def parameters(self, general_events: dict, mail_type: str) -> dict: + error_models = general_events.get('ERROR', {}).get('models', []) + warning_models = general_events.get('WARNING', {}).get('models', []) + info_models = general_events.get('INFO', {}).get('models', []) + return { 'mail_type': mail_type, 'error_events': self.replace_parameters(self.general_template, - general_events['ERROR']) if general_events['ERROR']['models'] else '', + error_models) if error_models else '', 'warning_events': self.replace_parameters(self.general_template, - general_events['WARNING']) if general_events['WARNING']['models'] else '', + warning_models) if warning_models else '', 'info_events': self.replace_parameters(self.general_template, - general_events['INFO']) if general_events['INFO']['models'] else '', + info_models) if info_models else '', } def build_email(self, report_data: list[dict], mail_type: str) -> str: diff --git a/orchestrator/workflows/alerts.py b/orchestrator/workflows/alerts.py index fd46f57..441390b 100644 --- a/orchestrator/workflows/alerts.py +++ b/orchestrator/workflows/alerts.py @@ -3,6 +3,8 @@ from temporalio import workflow with workflow.unsafe.imports_passed_through(): from orchestrator.activities.activities import Activities from typing import Any + from datetime import timedelta + from sientia_do.temporal.utils.policies import retry_policy @workflow.defn(name="alerts") @@ -62,7 +64,9 @@ class Alerts: 'notification_package': package['notification_package'], 'sending_configs': package['sending_configs'], 'notification_ttl': input_data['notification_ttl'] - } + }, + schedule_to_close_timeout=timedelta(seconds=60), + retry_policy=retry_policy ) # Call subworkflow "process_notifications" passing the notification package @@ -84,5 +88,7 @@ class Alerts: **metadata, 'log_report': log_report, 'sent_ttl': input_data['sent_ttl'] - } + }, + schedule_to_close_timeout=timedelta(seconds=60), + retry_policy=retry_policy ) diff --git a/orchestrator/workflows/subworkflows/load_notification_package.py b/orchestrator/workflows/subworkflows/load_notification_package.py index 8cbc76b..dd33887 100644 --- a/orchestrator/workflows/subworkflows/load_notification_package.py +++ b/orchestrator/workflows/subworkflows/load_notification_package.py @@ -75,6 +75,15 @@ class LoadNotificationPackage: 'sending_configs': await sending_configs_handler } + sending_configs = await sending_configs_handler + + if not sending_configs or not notification_package: + return { + 'last_timestamp': last_timestamp, + 'notification_package': notification_package, + 'sending_configs': sending_configs + } + # Put last collected timestamp in redis "notification_last_timestamp" await workflow.start_activity_method( diff --git a/orchestrator/workflows/subworkflows/process_notifications.py b/orchestrator/workflows/subworkflows/process_notifications.py index de71b27..f2f36c8 100644 --- a/orchestrator/workflows/subworkflows/process_notifications.py +++ b/orchestrator/workflows/subworkflows/process_notifications.py @@ -42,7 +42,8 @@ class ProcessNotifications: Activities.build_email_html, { **metadata, - "receiver_groups": input_data["notification_package"] + "receiver_groups": input_data["notification_package"], + "mail_type": input_data["mail_type"] }, schedule_to_close_timeout=timedelta(seconds=60), retry_policy=retry_policy @@ -68,6 +69,8 @@ class ProcessNotifications: "receiver_groups": log_report, "mail_type": input_data["mail_type"] }, + schedule_to_close_timeout=timedelta(seconds=60), + retry_policy=retry_policy ) # Store sending log in postgres database "log_report" From bc456cc8be97b0c0d42c2e34e2222857c9fcb7d1 Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Tue, 29 Jul 2025 10:25:31 -0300 Subject: [PATCH 06/14] SIENTIAPDE-1172 refactor: streamline email generation and enhance notification handling - Updated email HTML structure to improve clarity and presentation of notifications. - Enhanced notification generator to better manage execution counts and output formatting. - Refactored Email class to improve error handling and logging during email sending. - Adjusted EmailBuilder to efficiently process error, warning, and info models. - Improved test coverage for email and notification functionalities to ensure reliability. --- email.html | 226 +----------- notification_generator.ipynb | 343 ++++++++++++------ orchestrator/activities/email.py | 48 +-- orchestrator/activities/slot_manager.py | 2 + orchestrator/utils/email_builder.py | 9 +- .../utils/templates/general_template.html | 2 + .../subworkflows/load_notification_package.py | 10 +- .../activities/test_activities.py | 26 +- tests/orchestrator/activities/test_email.py | 28 +- .../orchestrator/activities/test_mongo_db.py | 32 +- .../utils/test_connectors_config.py | 71 +++- .../test_load_notification_package.py | 59 ++- .../test_process_notifications.py | 9 +- tests/orchestrator/workflows/test_alerts.py | 39 +- 14 files changed, 503 insertions(+), 401 deletions(-) diff --git a/email.html b/email.html index 4207ca5..c75e731 100644 --- a/email.html +++ b/email.html @@ -15,231 +15,11 @@

SIENTIA™ Alerts

-

Errors detected:

+

-

Model: do elit

-
OPC_LISTENNING_BACK__server_2sed_elit_ut_do2025-07-25 16:50:48.925462+00:00dolor et adipiscing adipiscing dolore ipsum ipsum aliqua et elitTAG_node76:tag_73_LISTENNING_STOPPEDtempor_elit2025-07-28 20:05:38.650875+00:00et do aliqua lorem incididunt
- - - - - - - - - - - - - - - - - - -
Notification IDBlockTimestampMessage
REPORT_PARTITION_MANAGERlorem_adipiscing2025-07-28 20:05:38.650661+00:00incididunt dolor lorem incididunt elit incididunt dolor consectetur ipsum ut
+

-

Model: dolor magna

- - - - - - - - - - - - - - - - - - - -
Notification IDBlockTimestampMessage
REPORT_PARTITION_MANAGERtempor_dolore_eiusmod2025-07-28 20:05:38.650910+00:00ipsum amet ipsum aliqua et eiusmod
- -

Model: labore sit lorem

- - - - - - - - - - - - - - - - - - - -
Notification IDBlockTimestampMessage
OPC_CONNECTION_RETRY__server_6dolor_labore_lorem2025-07-28 20:05:38.650949+00:00et adipiscing ipsum ut aliqua ut adipiscing aliqua amet
- -

Warnings detected:

- -

Model: incididunt adipiscing elit sit

- - - - - - - - - - - - - - - - - - - -
Notification IDBlockTimestampMessage
ADIPISCING_AMET_IPSUMeiusmod_aliqua_incididunt2025-07-28 20:05:38.650621+00:00magna incididunt dolore sed eiusmod sit incididunt ut
- -

Model: do adipiscing et

- - - - - - - - - - - - - - - - - - - -
Notification IDBlockTimestampMessage
TAG_node46:tag_73_LISTENNING_STOPPEDamet_dolor2025-07-28 20:05:38.650706+00:00dolore do et consectetur adipiscing do magna eiusmod eiusmod
- -

Model: lorem dolore amet

- - - - - - - - - - - - - - - - - - - -
Notification IDBlockTimestampMessage
TAG_node13:tag_59_LISTENNING_STOPPEDipsum_tempor_ipsum2025-07-28 20:05:38.650785+00:00et sed adipiscing tempor dolor magna do lorem
- -

Model: eiusmod sed

- - - - - - - - - - - - - - - - - - - -
Notification IDBlockTimestampMessage
OPC_SUBSCRIPTION__server_2do_ipsum2025-07-28 20:05:38.650826+00:00eiusmod et consectetur do dolore dolor sed amet
- -

Infos detected:

- -

Model: et sed

- - - - - - - - - - - - - - - - - - - -
Notification IDBlockTimestampMessage
OPC_LISTENNING_STOPPED__server_6labore_do_ipsum_ipsum2025-07-28 20:05:38.650570+00:00tempor dolore eiusmod sit sit sed elit
- -

Model: dolore sit

- - - - - - - - - - - - - - - - - - - -
Notification IDBlockTimestampMessage
REPORT_PARTITION_MANAGERet_lorem_incididunt_ipsum2025-07-28 20:05:38.650742+00:00adipiscing magna adipiscing adipiscing sit tempor
- -

Model: adipiscing do

- - - - - - - - - - - - - - - - - - - -
Notification IDBlockTimestampMessage
TAG_node76:tag_73_LISTENNING_STOPPEDtempor_elit2025-07-28 20:05:38.650875+00:00et do aliqua lorem incididunt
+

diff --git a/notification_generator.ipynb b/notification_generator.ipynb index 19a64c4..8d24f92 100644 --- a/notification_generator.ipynb +++ b/notification_generator.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 13, + "execution_count": 58, "id": "1786cbf0", "metadata": {}, "outputs": [ @@ -86,7 +86,144 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 59, + "id": "8a67c90d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'WARNING': {'section_name': 'Warnings detected:', 'models': [{'model_name': 'amet sed consectetur', 'events': [{'project': 'sientia-laborious', 'pipeline': 'elit_adipiscing_magna', 'trigger': 'eiusmod_do_incididunt_dolor_aliqua', 'model_name': 'amet sed consectetur', 'model_id': '8', 'block': 'do_amet_sit', 'level': 'WARNING', 'message': 'aliqua lorem elit magna eiusmod', 'attachment_content': 'sit ut consectetur dolore ut ipsum eiusmod tempor incididunt sit dolore elit aliqua amet amet aliqua magna dolore ut amet lorem aliqua dolore eiusmod', 'notification_id': 'OPC_CONNECTION_RETRY__server_9', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121908, tzinfo=datetime.timezone.utc)}]}, {'model_name': 'sit dolor lorem', 'events': [{'project': 'sientia-laborious', 'pipeline': 'consectetur_adipiscing_incididunt', 'trigger': 'labore_ut_amet_dolor', 'model_name': 'sit dolor lorem', 'model_id': '44', 'block': 'adipiscing_amet_aliqua_eiusmod', 'level': 'WARNING', 'message': 'consectetur elit incididunt dolore magna ut sit sit adipiscing', 'attachment_content': 'lorem ipsum elit eiusmod do lorem et do do eiusmod eiusmod dolore eiusmod lorem consectetur amet ipsum eiusmod dolor dolore aliqua adipiscing dolore magna ut', 'notification_id': 'OPC_LISTENNING_BACK__server_5', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121982, tzinfo=datetime.timezone.utc)}]}, {'model_name': 'ut labore ipsum ut', 'events': [{'project': 'sientia-laborious', 'pipeline': 'lorem_elit', 'trigger': 'magna_sed_tempor_labore', 'model_name': 'ut labore ipsum ut', 'model_id': '99', 'block': 'sed_dolor_do', 'level': 'WARNING', 'message': 'magna magna incididunt ut incididunt incididunt consectetur', 'attachment_content': 'sed lorem ut amet labore incididunt tempor incididunt do lorem aliqua dolore adipiscing labore labore tempor sit eiusmod tempor aliqua dolor ut sed incididunt adipiscing', 'notification_id': 'OPC_QUEUE_RETRIEVE__server_2', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 122025, tzinfo=datetime.timezone.utc)}]}]}, 'ERROR': {'section_name': 'Errors detected:', 'models': [{'model_name': 'ut elit et eiusmod', 'events': [{'project': 'sientia-laborious', 'pipeline': 'do_magna_aliqua_aliqua', 'trigger': 'dolore_sit_sed', 'model_name': 'ut elit et eiusmod', 'model_id': '20', 'block': 'do_magna', 'level': 'ERROR', 'message': 'dolor aliqua labore labore eiusmod do', 'attachment_content': 'eiusmod sed magna amet tempor labore magna adipiscing incididunt tempor ipsum ipsum aliqua magna tempor do tempor consectetur amet aliqua lorem sed lorem ut consectetur', 'notification_id': 'REPORT_PARTITION_MANAGER', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121924, tzinfo=datetime.timezone.utc)}]}, {'model_name': 'dolore aliqua eiusmod eiusmod', 'events': [{'project': 'sientia-laborious', 'pipeline': 'aliqua_dolor_elit', 'trigger': 'consectetur_incididunt_adipiscing', 'model_name': 'dolore aliqua eiusmod eiusmod', 'model_id': '97', 'block': 'consectetur_magna_do', 'level': 'ERROR', 'message': 'lorem eiusmod et aliqua aliqua sit', 'attachment_content': 'amet lorem labore amet eiusmod do do sit adipiscing ut adipiscing sed sed do ipsum tempor incididunt dolor sed et ipsum amet incididunt sed', 'notification_id': 'REPORT_PARTITION_MANAGER', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121953, tzinfo=datetime.timezone.utc)}]}, {'model_name': 'incididunt eiusmod amet eiusmod', 'events': [{'project': 'sientia-laborious', 'pipeline': 'ipsum_et', 'trigger': 'elit_amet_lorem_dolore_elit', 'model_name': 'incididunt eiusmod amet eiusmod', 'model_id': '31', 'block': 'amet_tempor_tempor', 'level': 'ERROR', 'message': 'ut dolor consectetur amet elit do elit', 'attachment_content': 'amet ut adipiscing lorem adipiscing ut et amet adipiscing incididunt elit magna et aliqua incididunt lorem incididunt lorem incididunt eiusmod sit amet tempor lorem', 'notification_id': 'ELIT_ALIQUA_TEMPOR', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 122040, tzinfo=datetime.timezone.utc)}]}]}, 'INFO': {'section_name': 'Infos detected:', 'models': [{'model_name': 'do magna', 'events': [{'project': 'sientia-laborious', 'pipeline': 'tempor_amet', 'trigger': 'incididunt_adipiscing_incididunt_eiusmod_incididunt_tempor', 'model_name': 'do magna', 'model_id': '29', 'block': 'incididunt_ipsum', 'level': 'INFO', 'message': 'sit labore consectetur labore magna labore eiusmod ipsum', 'attachment_content': 'ipsum amet et ipsum lorem consectetur incididunt dolore consectetur et elit eiusmod elit do eiusmod amet consectetur adipiscing adipiscing lorem eiusmod magna dolor eiusmod sit eiusmod lorem amet magna consectetur', 'notification_id': 'OPC_SUBSCRIPTION__server_4', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121940, tzinfo=datetime.timezone.utc)}]}, {'model_name': 'amet adipiscing ut', 'events': [{'project': 'sientia-laborious', 'pipeline': 'aliqua_dolore_adipiscing_adipiscing', 'trigger': 'ut_incididunt_ut_et_tempor_incididunt', 'model_name': 'amet adipiscing ut', 'model_id': '28', 'block': 'do_sed', 'level': 'INFO', 'message': 'consectetur elit amet dolor incididunt dolor dolor labore adipiscing', 'attachment_content': 'do incididunt aliqua tempor dolor sed lorem elit consectetur et tempor labore elit sed elit dolor sed amet ipsum do eiusmod aliqua adipiscing et sit', 'notification_id': 'TAG_node56:tag_30_LISTENNING_STOPPED', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121968, tzinfo=datetime.timezone.utc)}]}, {'model_name': 'sed lorem et lorem', 'events': [{'project': 'sientia-laborious', 'pipeline': 'do_ipsum_consectetur_ut', 'trigger': 'dolore_incididunt_consectetur_et', 'model_name': 'sed lorem et lorem', 'model_id': '82', 'block': 'tempor_incididunt', 'level': 'INFO', 'message': 'lorem tempor eiusmod dolore tempor aliqua amet sed', 'attachment_content': 'sed dolore amet ut eiusmod aliqua sed dolore incididunt consectetur adipiscing et elit magna consectetur ipsum ipsum ipsum dolor eiusmod do', 'notification_id': 'REPORT_PARTITION_MANAGER', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121994, tzinfo=datetime.timezone.utc)}]}, {'model_name': 'eiusmod elit incididunt', 'events': [{'project': 'sientia-laborious', 'pipeline': 'sit_dolore', 'trigger': 'eiusmod_sed_amet_ut', 'model_name': 'eiusmod elit incididunt', 'model_id': '85', 'block': 'amet_incididunt', 'level': 'INFO', 'message': 'do consectetur incididunt magna dolore et et elit incididunt', 'attachment_content': 'tempor eiusmod sit elit consectetur ut consectetur eiusmod labore labore consectetur ut eiusmod sit dolor ipsum sit do et amet lorem lorem et do ut magna dolor', 'notification_id': 'REPORT_PARTITION_MANAGER', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 122010, tzinfo=datetime.timezone.utc)}]}]}}\n" + ] + } + ], + "source": [ + "from orchestrator.utils.email_builder import EmailBuilder\n", + "from unittest.mock import MagicMock\n", + "\n", + "\n", + "email_builder = EmailBuilder(logger=MagicMock())\n", + "\n", + "\n", + "general_events = {}\n", + "\n", + "for report in notifications:\n", + "\n", + " level = report['level']\n", + " model_name = report['model_name']\n", + "\n", + " if level not in general_events:\n", + " general_events[level] = {\n", + " 'section_name': f'{level.capitalize()}s detected:',\n", + " 'models': {}\n", + " }\n", + "\n", + " if model_name not in general_events[level]['models']:\n", + " general_events[level]['models'][model_name] = {\n", + " 'model_name': model_name,\n", + " 'events': []\n", + " }\n", + "\n", + " general_events[level]['models'][model_name]['events'].append(\n", + " report)\n", + "\n", + "for _type, content in general_events.items():\n", + " content['models'] = list(content['models'].values())\n", + "\n", + "print(general_events)" + ] + }, + { + "cell_type": "code", + "execution_count": 60, + "id": "6944697a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[{'model_name': 'ut elit et eiusmod', 'events': [{'project': 'sientia-laborious', 'pipeline': 'do_magna_aliqua_aliqua', 'trigger': 'dolore_sit_sed', 'model_name': 'ut elit et eiusmod', 'model_id': '20', 'block': 'do_magna', 'level': 'ERROR', 'message': 'dolor aliqua labore labore eiusmod do', 'attachment_content': 'eiusmod sed magna amet tempor labore magna adipiscing incididunt tempor ipsum ipsum aliqua magna tempor do tempor consectetur amet aliqua lorem sed lorem ut consectetur', 'notification_id': 'REPORT_PARTITION_MANAGER', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121924, tzinfo=datetime.timezone.utc)}]}, {'model_name': 'dolore aliqua eiusmod eiusmod', 'events': [{'project': 'sientia-laborious', 'pipeline': 'aliqua_dolor_elit', 'trigger': 'consectetur_incididunt_adipiscing', 'model_name': 'dolore aliqua eiusmod eiusmod', 'model_id': '97', 'block': 'consectetur_magna_do', 'level': 'ERROR', 'message': 'lorem eiusmod et aliqua aliqua sit', 'attachment_content': 'amet lorem labore amet eiusmod do do sit adipiscing ut adipiscing sed sed do ipsum tempor incididunt dolor sed et ipsum amet incididunt sed', 'notification_id': 'REPORT_PARTITION_MANAGER', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121953, tzinfo=datetime.timezone.utc)}]}, {'model_name': 'incididunt eiusmod amet eiusmod', 'events': [{'project': 'sientia-laborious', 'pipeline': 'ipsum_et', 'trigger': 'elit_amet_lorem_dolore_elit', 'model_name': 'incididunt eiusmod amet eiusmod', 'model_id': '31', 'block': 'amet_tempor_tempor', 'level': 'ERROR', 'message': 'ut dolor consectetur amet elit do elit', 'attachment_content': 'amet ut adipiscing lorem adipiscing ut et amet adipiscing incididunt elit magna et aliqua incididunt lorem incididunt lorem incididunt eiusmod sit amet tempor lorem', 'notification_id': 'ELIT_ALIQUA_TEMPOR', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 122040, tzinfo=datetime.timezone.utc)}]}]\n", + "[{'model_name': 'amet sed consectetur', 'events': [{'project': 'sientia-laborious', 'pipeline': 'elit_adipiscing_magna', 'trigger': 'eiusmod_do_incididunt_dolor_aliqua', 'model_name': 'amet sed consectetur', 'model_id': '8', 'block': 'do_amet_sit', 'level': 'WARNING', 'message': 'aliqua lorem elit magna eiusmod', 'attachment_content': 'sit ut consectetur dolore ut ipsum eiusmod tempor incididunt sit dolore elit aliqua amet amet aliqua magna dolore ut amet lorem aliqua dolore eiusmod', 'notification_id': 'OPC_CONNECTION_RETRY__server_9', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121908, tzinfo=datetime.timezone.utc)}]}, {'model_name': 'sit dolor lorem', 'events': [{'project': 'sientia-laborious', 'pipeline': 'consectetur_adipiscing_incididunt', 'trigger': 'labore_ut_amet_dolor', 'model_name': 'sit dolor lorem', 'model_id': '44', 'block': 'adipiscing_amet_aliqua_eiusmod', 'level': 'WARNING', 'message': 'consectetur elit incididunt dolore magna ut sit sit adipiscing', 'attachment_content': 'lorem ipsum elit eiusmod do lorem et do do eiusmod eiusmod dolore eiusmod lorem consectetur amet ipsum eiusmod dolor dolore aliqua adipiscing dolore magna ut', 'notification_id': 'OPC_LISTENNING_BACK__server_5', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121982, tzinfo=datetime.timezone.utc)}]}, {'model_name': 'ut labore ipsum ut', 'events': [{'project': 'sientia-laborious', 'pipeline': 'lorem_elit', 'trigger': 'magna_sed_tempor_labore', 'model_name': 'ut labore ipsum ut', 'model_id': '99', 'block': 'sed_dolor_do', 'level': 'WARNING', 'message': 'magna magna incididunt ut incididunt incididunt consectetur', 'attachment_content': 'sed lorem ut amet labore incididunt tempor incididunt do lorem aliqua dolore adipiscing labore labore tempor sit eiusmod tempor aliqua dolor ut sed incididunt adipiscing', 'notification_id': 'OPC_QUEUE_RETRIEVE__server_2', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 122025, tzinfo=datetime.timezone.utc)}]}]\n", + "[{'model_name': 'do magna', 'events': [{'project': 'sientia-laborious', 'pipeline': 'tempor_amet', 'trigger': 'incididunt_adipiscing_incididunt_eiusmod_incididunt_tempor', 'model_name': 'do magna', 'model_id': '29', 'block': 'incididunt_ipsum', 'level': 'INFO', 'message': 'sit labore consectetur labore magna labore eiusmod ipsum', 'attachment_content': 'ipsum amet et ipsum lorem consectetur incididunt dolore consectetur et elit eiusmod elit do eiusmod amet consectetur adipiscing adipiscing lorem eiusmod magna dolor eiusmod sit eiusmod lorem amet magna consectetur', 'notification_id': 'OPC_SUBSCRIPTION__server_4', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121940, tzinfo=datetime.timezone.utc)}]}, {'model_name': 'amet adipiscing ut', 'events': [{'project': 'sientia-laborious', 'pipeline': 'aliqua_dolore_adipiscing_adipiscing', 'trigger': 'ut_incididunt_ut_et_tempor_incididunt', 'model_name': 'amet adipiscing ut', 'model_id': '28', 'block': 'do_sed', 'level': 'INFO', 'message': 'consectetur elit amet dolor incididunt dolor dolor labore adipiscing', 'attachment_content': 'do incididunt aliqua tempor dolor sed lorem elit consectetur et tempor labore elit sed elit dolor sed amet ipsum do eiusmod aliqua adipiscing et sit', 'notification_id': 'TAG_node56:tag_30_LISTENNING_STOPPED', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121968, tzinfo=datetime.timezone.utc)}]}, {'model_name': 'sed lorem et lorem', 'events': [{'project': 'sientia-laborious', 'pipeline': 'do_ipsum_consectetur_ut', 'trigger': 'dolore_incididunt_consectetur_et', 'model_name': 'sed lorem et lorem', 'model_id': '82', 'block': 'tempor_incididunt', 'level': 'INFO', 'message': 'lorem tempor eiusmod dolore tempor aliqua amet sed', 'attachment_content': 'sed dolore amet ut eiusmod aliqua sed dolore incididunt consectetur adipiscing et elit magna consectetur ipsum ipsum ipsum dolor eiusmod do', 'notification_id': 'REPORT_PARTITION_MANAGER', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121994, tzinfo=datetime.timezone.utc)}]}, {'model_name': 'eiusmod elit incididunt', 'events': [{'project': 'sientia-laborious', 'pipeline': 'sit_dolore', 'trigger': 'eiusmod_sed_amet_ut', 'model_name': 'eiusmod elit incididunt', 'model_id': '85', 'block': 'amet_incididunt', 'level': 'INFO', 'message': 'do consectetur incididunt magna dolore et et elit incididunt', 'attachment_content': 'tempor eiusmod sit elit consectetur ut consectetur eiusmod labore labore consectetur ut eiusmod sit dolor ipsum sit do et amet lorem lorem et do ut magna dolor', 'notification_id': 'REPORT_PARTITION_MANAGER', 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 122010, tzinfo=datetime.timezone.utc)}]}]\n" + ] + } + ], + "source": [ + "error_models = general_events.get('ERROR', {}).get('models', [])\n", + "warning_models = general_events.get('WARNING', {}).get('models', [])\n", + "info_models = general_events.get('INFO', {}).get('models', [])\n", + "mail_type = 'Alerts'\n", + "\n", + "print(error_models)\n", + "print(warning_models)\n", + "print(info_models)" + ] + }, + { + "cell_type": "code", + "execution_count": 61, + "id": "02e7dabf", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'

Errors detected:

\\n\\n

Model: ut elit et eiusmod

\\n\\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n
Notification IDScheduleBlockTimestampMessage
REPORT_PARTITION_MANAGERdolore_sit_seddo_magna2025-07-29 12:21:45.121924+00:00dolor aliqua labore labore eiusmod do
\\n\\n

Model: dolore aliqua eiusmod eiusmod

\\n\\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n
Notification IDScheduleBlockTimestampMessage
REPORT_PARTITION_MANAGERconsectetur_incididunt_adipiscingconsectetur_magna_do2025-07-29 12:21:45.121953+00:00lorem eiusmod et aliqua aliqua sit
\\n\\n

Model: incididunt eiusmod amet eiusmod

\\n\\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n \\n
Notification IDScheduleBlockTimestampMessage
ELIT_ALIQUA_TEMPORelit_amet_lorem_dolore_elitamet_tempor_tempor2025-07-29 12:21:45.122040+00:00ut dolor consectetur amet elit do elit
\\n'" + ] + }, + "execution_count": 61, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "email_builder.replace_parameters(email_builder.general_template,\n", + " general_events.get('ERROR', {})) if general_events else ''" + ] + }, + { + "cell_type": "code", + "execution_count": 62, + "id": "bebde0ea", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'mail_type': 'Alerts',\n", + " 'error_events': '

\\n',\n", + " 'warning_events': '

\\n',\n", + " 'info_events': '

\\n'}" + ] + }, + "execution_count": 62, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "\n", + "\n", + "\n", + "\n", + "d = {\n", + " 'mail_type': mail_type,\n", + " 'error_events': email_builder.replace_parameters(email_builder.general_template,\n", + " error_models) if error_models else '',\n", + " 'warning_events': email_builder.replace_parameters(email_builder.general_template,\n", + " warning_models) if warning_models else '',\n", + " 'info_events': email_builder.replace_parameters(email_builder.general_template,\n", + " info_models) if info_models else '',\n", + "}\n", + "\n", + "d" + ] + }, + { + "cell_type": "code", + "execution_count": 63, "id": "c1467b72", "metadata": {}, "outputs": [], @@ -105,7 +242,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 64, "id": "ffd7be77", "metadata": {}, "outputs": [ @@ -113,118 +250,118 @@ "data": { "text/plain": [ "[{'project': 'sientia-laborious',\n", - " 'pipeline': 'magna_incididunt',\n", - " 'trigger': 'dolor_dolor_aliqua_ipsum_do_magna',\n", - " 'model_name': 'et sed',\n", - " 'model_id': '53',\n", - " 'block': 'labore_do_ipsum_ipsum',\n", - " 'level': 'INFO',\n", - " 'message': 'tempor dolore eiusmod sit sit sed elit',\n", - " 'attachment_content': 'sit labore sed amet sit magna ut ut labore do ut adipiscing amet incididunt magna do sit eiusmod dolor do sit incididunt amet incididunt consectetur elit et ipsum elit amet',\n", - " 'notification_id': 'OPC_LISTENNING_STOPPED__server_6',\n", - " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650570, tzinfo=datetime.timezone.utc)},\n", - " {'project': 'sientia-laborious',\n", - " 'pipeline': 'adipiscing_do',\n", - " 'trigger': 'sit_lorem_dolore',\n", - " 'model_name': 'incididunt adipiscing elit sit',\n", - " 'model_id': '64',\n", - " 'block': 'eiusmod_aliqua_incididunt',\n", + " 'pipeline': 'elit_adipiscing_magna',\n", + " 'trigger': 'eiusmod_do_incididunt_dolor_aliqua',\n", + " 'model_name': 'amet sed consectetur',\n", + " 'model_id': '8',\n", + " 'block': 'do_amet_sit',\n", " 'level': 'WARNING',\n", - " 'message': 'magna incididunt dolore sed eiusmod sit incididunt ut',\n", - " 'attachment_content': 'incididunt incididunt adipiscing do ipsum tempor ut eiusmod sit tempor tempor aliqua aliqua dolor labore incididunt amet do sed tempor dolor labore',\n", - " 'notification_id': 'ADIPISCING_AMET_IPSUM',\n", - " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650621, tzinfo=datetime.timezone.utc)},\n", + " 'message': 'aliqua lorem elit magna eiusmod',\n", + " 'attachment_content': 'sit ut consectetur dolore ut ipsum eiusmod tempor incididunt sit dolore elit aliqua amet amet aliqua magna dolore ut amet lorem aliqua dolore eiusmod',\n", + " 'notification_id': 'OPC_CONNECTION_RETRY__server_9',\n", + " 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121908, tzinfo=datetime.timezone.utc)},\n", " {'project': 'sientia-laborious',\n", - " 'pipeline': 'sed_aliqua',\n", - " 'trigger': 'aliqua_adipiscing_adipiscing_sed_do_sit',\n", - " 'model_name': 'do elit',\n", - " 'model_id': '73',\n", - " 'block': 'lorem_adipiscing',\n", + " 'pipeline': 'do_magna_aliqua_aliqua',\n", + " 'trigger': 'dolore_sit_sed',\n", + " 'model_name': 'ut elit et eiusmod',\n", + " 'model_id': '20',\n", + " 'block': 'do_magna',\n", " 'level': 'ERROR',\n", - " 'message': 'incididunt dolor lorem incididunt elit incididunt dolor consectetur ipsum ut',\n", - " 'attachment_content': 'dolor ut do amet dolore dolor do eiusmod eiusmod incididunt aliqua et sed labore incididunt aliqua sed et tempor consectetur elit et consectetur do ipsum',\n", + " 'message': 'dolor aliqua labore labore eiusmod do',\n", + " 'attachment_content': 'eiusmod sed magna amet tempor labore magna adipiscing incididunt tempor ipsum ipsum aliqua magna tempor do tempor consectetur amet aliqua lorem sed lorem ut consectetur',\n", " 'notification_id': 'REPORT_PARTITION_MANAGER',\n", - " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650661, tzinfo=datetime.timezone.utc)},\n", + " 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121924, tzinfo=datetime.timezone.utc)},\n", " {'project': 'sientia-laborious',\n", - " 'pipeline': 'dolore_et_aliqua_et',\n", - " 'trigger': 'adipiscing_amet_sit_do',\n", - " 'model_name': 'do adipiscing et',\n", - " 'model_id': '10',\n", - " 'block': 'amet_dolor',\n", - " 'level': 'WARNING',\n", - " 'message': 'dolore do et consectetur adipiscing do magna eiusmod eiusmod',\n", - " 'attachment_content': 'sit adipiscing adipiscing consectetur magna sed magna adipiscing dolor incididunt et adipiscing adipiscing elit et labore tempor tempor adipiscing sit dolore sed dolore dolor labore sed',\n", - " 'notification_id': 'TAG_node46:tag_73_LISTENNING_STOPPED',\n", - " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650706, tzinfo=datetime.timezone.utc)},\n", - " {'project': 'sientia-laborious',\n", - " 'pipeline': 'ipsum_lorem_sit_sit',\n", - " 'trigger': 'et_ut_sit_dolore',\n", - " 'model_name': 'dolore sit',\n", - " 'model_id': '58',\n", - " 'block': 'et_lorem_incididunt_ipsum',\n", + " 'pipeline': 'tempor_amet',\n", + " 'trigger': 'incididunt_adipiscing_incididunt_eiusmod_incididunt_tempor',\n", + " 'model_name': 'do magna',\n", + " 'model_id': '29',\n", + " 'block': 'incididunt_ipsum',\n", " 'level': 'INFO',\n", - " 'message': 'adipiscing magna adipiscing adipiscing sit tempor',\n", - " 'attachment_content': 'incididunt amet dolor incididunt dolore et lorem amet ut magna tempor amet do consectetur dolore magna elit aliqua eiusmod ipsum labore',\n", + " 'message': 'sit labore consectetur labore magna labore eiusmod ipsum',\n", + " 'attachment_content': 'ipsum amet et ipsum lorem consectetur incididunt dolore consectetur et elit eiusmod elit do eiusmod amet consectetur adipiscing adipiscing lorem eiusmod magna dolor eiusmod sit eiusmod lorem amet magna consectetur',\n", + " 'notification_id': 'OPC_SUBSCRIPTION__server_4',\n", + " 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121940, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'aliqua_dolor_elit',\n", + " 'trigger': 'consectetur_incididunt_adipiscing',\n", + " 'model_name': 'dolore aliqua eiusmod eiusmod',\n", + " 'model_id': '97',\n", + " 'block': 'consectetur_magna_do',\n", + " 'level': 'ERROR',\n", + " 'message': 'lorem eiusmod et aliqua aliqua sit',\n", + " 'attachment_content': 'amet lorem labore amet eiusmod do do sit adipiscing ut adipiscing sed sed do ipsum tempor incididunt dolor sed et ipsum amet incididunt sed',\n", " 'notification_id': 'REPORT_PARTITION_MANAGER',\n", - " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650742, tzinfo=datetime.timezone.utc)},\n", + " 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121953, tzinfo=datetime.timezone.utc)},\n", " {'project': 'sientia-laborious',\n", - " 'pipeline': 'dolor_sit_aliqua',\n", - " 'trigger': 'lorem_incididunt_labore',\n", - " 'model_name': 'lorem dolore amet',\n", - " 'model_id': '13',\n", - " 'block': 'ipsum_tempor_ipsum',\n", - " 'level': 'WARNING',\n", - " 'message': 'et sed adipiscing tempor dolor magna do lorem',\n", - " 'attachment_content': 'do incididunt dolore eiusmod adipiscing amet amet amet eiusmod lorem dolor labore labore ipsum dolore amet labore ipsum labore do amet',\n", - " 'notification_id': 'TAG_node13:tag_59_LISTENNING_STOPPED',\n", - " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650785, tzinfo=datetime.timezone.utc)},\n", - " {'project': 'sientia-laborious',\n", - " 'pipeline': 'lorem_do_consectetur',\n", - " 'trigger': 'sit_aliqua_sit',\n", - " 'model_name': 'eiusmod sed',\n", - " 'model_id': '9',\n", - " 'block': 'do_ipsum',\n", - " 'level': 'WARNING',\n", - " 'message': 'eiusmod et consectetur do dolore dolor sed amet',\n", - " 'attachment_content': 'sed dolore dolore et et dolore dolor eiusmod aliqua adipiscing incididunt dolor lorem tempor consectetur dolor amet elit et aliqua lorem ipsum consectetur tempor tempor elit labore labore dolore',\n", - " 'notification_id': 'OPC_SUBSCRIPTION__server_2',\n", - " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650826, tzinfo=datetime.timezone.utc)},\n", - " {'project': 'sientia-laborious',\n", - " 'pipeline': 'labore_elit_adipiscing',\n", - " 'trigger': 'dolore_incididunt_tempor_lorem_dolore',\n", - " 'model_name': 'adipiscing do',\n", - " 'model_id': '63',\n", - " 'block': 'tempor_elit',\n", + " 'pipeline': 'aliqua_dolore_adipiscing_adipiscing',\n", + " 'trigger': 'ut_incididunt_ut_et_tempor_incididunt',\n", + " 'model_name': 'amet adipiscing ut',\n", + " 'model_id': '28',\n", + " 'block': 'do_sed',\n", " 'level': 'INFO',\n", - " 'message': 'et do aliqua lorem incididunt',\n", - " 'attachment_content': 'ut eiusmod consectetur sit labore ut tempor et sit sit do incididunt incididunt sed eiusmod dolor adipiscing dolor ut sed amet labore amet amet sed et elit sed ipsum',\n", - " 'notification_id': 'TAG_node76:tag_73_LISTENNING_STOPPED',\n", - " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650875, tzinfo=datetime.timezone.utc)},\n", + " 'message': 'consectetur elit amet dolor incididunt dolor dolor labore adipiscing',\n", + " 'attachment_content': 'do incididunt aliqua tempor dolor sed lorem elit consectetur et tempor labore elit sed elit dolor sed amet ipsum do eiusmod aliqua adipiscing et sit',\n", + " 'notification_id': 'TAG_node56:tag_30_LISTENNING_STOPPED',\n", + " 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121968, tzinfo=datetime.timezone.utc)},\n", " {'project': 'sientia-laborious',\n", - " 'pipeline': 'ut_lorem_et',\n", - " 'trigger': 'labore_labore_ipsum_magna',\n", - " 'model_name': 'dolor magna',\n", - " 'model_id': '74',\n", - " 'block': 'tempor_dolore_eiusmod',\n", - " 'level': 'ERROR',\n", - " 'message': 'ipsum amet ipsum aliqua et eiusmod',\n", - " 'attachment_content': 'incididunt dolore do sit sed ut sed sit magna sed amet elit elit consectetur dolor labore magna consectetur tempor magna',\n", + " 'pipeline': 'consectetur_adipiscing_incididunt',\n", + " 'trigger': 'labore_ut_amet_dolor',\n", + " 'model_name': 'sit dolor lorem',\n", + " 'model_id': '44',\n", + " 'block': 'adipiscing_amet_aliqua_eiusmod',\n", + " 'level': 'WARNING',\n", + " 'message': 'consectetur elit incididunt dolore magna ut sit sit adipiscing',\n", + " 'attachment_content': 'lorem ipsum elit eiusmod do lorem et do do eiusmod eiusmod dolore eiusmod lorem consectetur amet ipsum eiusmod dolor dolore aliqua adipiscing dolore magna ut',\n", + " 'notification_id': 'OPC_LISTENNING_BACK__server_5',\n", + " 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121982, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'do_ipsum_consectetur_ut',\n", + " 'trigger': 'dolore_incididunt_consectetur_et',\n", + " 'model_name': 'sed lorem et lorem',\n", + " 'model_id': '82',\n", + " 'block': 'tempor_incididunt',\n", + " 'level': 'INFO',\n", + " 'message': 'lorem tempor eiusmod dolore tempor aliqua amet sed',\n", + " 'attachment_content': 'sed dolore amet ut eiusmod aliqua sed dolore incididunt consectetur adipiscing et elit magna consectetur ipsum ipsum ipsum dolor eiusmod do',\n", " 'notification_id': 'REPORT_PARTITION_MANAGER',\n", - " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650910, tzinfo=datetime.timezone.utc)},\n", + " 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 121994, tzinfo=datetime.timezone.utc)},\n", " {'project': 'sientia-laborious',\n", - " 'pipeline': 'magna_sit_amet_sed',\n", - " 'trigger': 'ut_dolor_sed',\n", - " 'model_name': 'labore sit lorem',\n", - " 'model_id': '13',\n", - " 'block': 'dolor_labore_lorem',\n", + " 'pipeline': 'sit_dolore',\n", + " 'trigger': 'eiusmod_sed_amet_ut',\n", + " 'model_name': 'eiusmod elit incididunt',\n", + " 'model_id': '85',\n", + " 'block': 'amet_incididunt',\n", + " 'level': 'INFO',\n", + " 'message': 'do consectetur incididunt magna dolore et et elit incididunt',\n", + " 'attachment_content': 'tempor eiusmod sit elit consectetur ut consectetur eiusmod labore labore consectetur ut eiusmod sit dolor ipsum sit do et amet lorem lorem et do ut magna dolor',\n", + " 'notification_id': 'REPORT_PARTITION_MANAGER',\n", + " 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 122010, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'lorem_elit',\n", + " 'trigger': 'magna_sed_tempor_labore',\n", + " 'model_name': 'ut labore ipsum ut',\n", + " 'model_id': '99',\n", + " 'block': 'sed_dolor_do',\n", + " 'level': 'WARNING',\n", + " 'message': 'magna magna incididunt ut incididunt incididunt consectetur',\n", + " 'attachment_content': 'sed lorem ut amet labore incididunt tempor incididunt do lorem aliqua dolore adipiscing labore labore tempor sit eiusmod tempor aliqua dolor ut sed incididunt adipiscing',\n", + " 'notification_id': 'OPC_QUEUE_RETRIEVE__server_2',\n", + " 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 122025, tzinfo=datetime.timezone.utc)},\n", + " {'project': 'sientia-laborious',\n", + " 'pipeline': 'ipsum_et',\n", + " 'trigger': 'elit_amet_lorem_dolore_elit',\n", + " 'model_name': 'incididunt eiusmod amet eiusmod',\n", + " 'model_id': '31',\n", + " 'block': 'amet_tempor_tempor',\n", " 'level': 'ERROR',\n", - " 'message': 'et adipiscing ipsum ut aliqua ut adipiscing aliqua amet',\n", - " 'attachment_content': 'aliqua labore aliqua lorem labore aliqua sed sit magna ut do amet do eiusmod sit consectetur sed ipsum elit amet aliqua labore sit',\n", - " 'notification_id': 'OPC_CONNECTION_RETRY__server_6',\n", - " 'timestamp': datetime.datetime(2025, 7, 28, 20, 5, 38, 650949, tzinfo=datetime.timezone.utc)}]" + " 'message': 'ut dolor consectetur amet elit do elit',\n", + " 'attachment_content': 'amet ut adipiscing lorem adipiscing ut et amet adipiscing incididunt elit magna et aliqua incididunt lorem incididunt lorem incididunt eiusmod sit amet tempor lorem',\n", + " 'notification_id': 'ELIT_ALIQUA_TEMPOR',\n", + " 'timestamp': datetime.datetime(2025, 7, 29, 12, 21, 45, 122040, tzinfo=datetime.timezone.utc)}]" ] }, - "execution_count": 15, + "execution_count": 64, "metadata": {}, "output_type": "execute_result" } @@ -235,17 +372,17 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 65, "id": "55aed216", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "InsertManyResult([ObjectId('6887d812645b7515e8402983'), ObjectId('6887d812645b7515e8402984'), ObjectId('6887d812645b7515e8402985'), ObjectId('6887d812645b7515e8402986'), ObjectId('6887d812645b7515e8402987'), ObjectId('6887d812645b7515e8402988'), ObjectId('6887d812645b7515e8402989'), ObjectId('6887d812645b7515e840298a'), ObjectId('6887d812645b7515e840298b'), ObjectId('6887d812645b7515e840298c')], acknowledged=True)" + "InsertManyResult([ObjectId('6888bcd90ccc3300e4a6c2e5'), ObjectId('6888bcd90ccc3300e4a6c2e6'), ObjectId('6888bcd90ccc3300e4a6c2e7'), ObjectId('6888bcd90ccc3300e4a6c2e8'), ObjectId('6888bcd90ccc3300e4a6c2e9'), ObjectId('6888bcd90ccc3300e4a6c2ea'), ObjectId('6888bcd90ccc3300e4a6c2eb'), ObjectId('6888bcd90ccc3300e4a6c2ec'), ObjectId('6888bcd90ccc3300e4a6c2ed'), ObjectId('6888bcd90ccc3300e4a6c2ee')], acknowledged=True)" ] }, - "execution_count": 16, + "execution_count": 65, "metadata": {}, "output_type": "execute_result" } diff --git a/orchestrator/activities/email.py b/orchestrator/activities/email.py index b21321d..e85ee30 100644 --- a/orchestrator/activities/email.py +++ b/orchestrator/activities/email.py @@ -95,6 +95,8 @@ class Email(BaseActivity): self.logger.error( f"Failed to attach content of {att_name}: {e}") + raise e + return msg @activity.defn(name="send_email") @@ -113,29 +115,29 @@ class Email(BaseActivity): metadata=metadata) for group_name, group_config in receiver_groups.items(): - - receivers = ", ".join(group_config['members']) - - self.info(f"Sending email to {group_name}: {receivers}", - metadata=metadata) - - msg = MIMEMultipart() - msg.attach(MIMEText(group_config['html'], 'html')) - msg['From'] = self.sender_email - msg['To'] = receivers - msg['Subject'] = f"SIENTIA™ {mail_type}" - - msg = self.handle_attachments( - [ - { - "filename": f"{notification['trigger']}_{notification['notification_id']}.txt", - "content": notification['attachment_content'] - } - for notification in group_config['notifications'] - if notification['attachment_content']], - msg) - try: + + receivers = ", ".join(group_config['members']) + + self.info(f"Sending email to {group_name}: {receivers}", + metadata=metadata) + + msg = MIMEMultipart() + msg.attach(MIMEText(group_config['html'], 'html')) + msg['From'] = self.sender_email + msg['To'] = receivers + msg['Subject'] = f"SIENTIA™ {mail_type}" + + msg = self.handle_attachments( + [ + { + "filename": f"{notification['trigger']}_{notification['notification_id']}.txt", + "attachment_content": notification['attachment_content'] + } + for notification in group_config['notifications'] + if notification.get('attachment_content') is not None], + msg) + self.server.sendmail( self.sender_email, receivers, msg.as_string()) except Exception as e: @@ -146,7 +148,7 @@ class Email(BaseActivity): else: group_config['status'] = 'sent' - self.info(f"Email sent to {group_name}.", + self.info(f"Email sent to {group_name}: {receivers}", metadata=metadata) self.info(f"Email sent for {mail_type} mail type.", diff --git a/orchestrator/activities/slot_manager.py b/orchestrator/activities/slot_manager.py index 40ef29a..17dbd07 100644 --- a/orchestrator/activities/slot_manager.py +++ b/orchestrator/activities/slot_manager.py @@ -335,3 +335,5 @@ class SlotManager(Redis): if status == 'sent': key = f"{row['schedule']}:{row['notification_id']}" self.set(key, now, ttl=sent_ttl) + + self.info("Notification cache stored...", metadata=metadata) diff --git a/orchestrator/utils/email_builder.py b/orchestrator/utils/email_builder.py index e01d580..88a765f 100644 --- a/orchestrator/utils/email_builder.py +++ b/orchestrator/utils/email_builder.py @@ -31,11 +31,14 @@ class EmailBuilder: return { 'mail_type': mail_type, 'error_events': self.replace_parameters(self.general_template, - error_models) if error_models else '', + general_events.get( + 'ERROR')) if error_models else '', 'warning_events': self.replace_parameters(self.general_template, - warning_models) if warning_models else '', + general_events.get( + 'WARNING')) if warning_models else '', 'info_events': self.replace_parameters(self.general_template, - info_models) if info_models else '', + general_events.get( + 'INFO')) if info_models else '', } def build_email(self, report_data: list[dict], mail_type: str) -> str: diff --git a/orchestrator/utils/templates/general_template.html b/orchestrator/utils/templates/general_template.html index 7736178..0ae39fa 100644 --- a/orchestrator/utils/templates/general_template.html +++ b/orchestrator/utils/templates/general_template.html @@ -5,6 +5,7 @@ Notification ID + Schedule Block Timestamp Message @@ -14,6 +15,7 @@ {% for event in model.events %} {{ event.notification_id }} + {{ event.trigger }} {{ event.block }} {{ event.timestamp }} {{ event.message }} diff --git a/orchestrator/workflows/subworkflows/load_notification_package.py b/orchestrator/workflows/subworkflows/load_notification_package.py index dd33887..83a1c0f 100644 --- a/orchestrator/workflows/subworkflows/load_notification_package.py +++ b/orchestrator/workflows/subworkflows/load_notification_package.py @@ -67,14 +67,6 @@ class LoadNotificationPackage: start_to_close_timeout=timedelta(seconds=60), retry_policy=retry_policy ) - - if not notification_package: - return { - 'last_timestamp': last_timestamp, - 'notification_package': [], - 'sending_configs': await sending_configs_handler - } - sending_configs = await sending_configs_handler if not sending_configs or not notification_package: @@ -103,5 +95,5 @@ class LoadNotificationPackage: return { 'last_timestamp': last_timestamp, 'notification_package': notification_package, - 'sending_configs': await sending_configs_handler + 'sending_configs': sending_configs } diff --git a/tests/orchestrator/activities/test_activities.py b/tests/orchestrator/activities/test_activities.py index 1d76bae..d2640d1 100644 --- a/tests/orchestrator/activities/test_activities.py +++ b/tests/orchestrator/activities/test_activities.py @@ -12,7 +12,12 @@ from orchestrator.activities.formatters import Formatters @patch('orchestrator.activities.temporal_manager.TemporalManager.__init__') @patch('orchestrator.activities.slot_manager.SlotManager.__init__') @patch('orchestrator.activities.formatters.Formatters.__init__') -def test___init__(mock_formatters_init, mock_slot_manager_init, +@patch('orchestrator.activities.email.Email.__init__') +@patch('sientia_do.temporal.activities.postgres.Postgres.__init__') +def test___init__(mock_postgres_init, + mock_email_init, + mock_formatters_init, + mock_slot_manager_init, mock_temporal_manager_init, mock_mongodb_init): @@ -35,6 +40,23 @@ def test___init__(mock_formatters_init, mock_slot_manager_init, 'temporal_laborious_namespace': 'laborious' } + email_config = { + 'sender_email': 'test@test.com', + 'sender_password': 'test', + 'smtp_server': 'test', + 'smtp_port': 587 + } + + postgres_config = { + 'host': 'localhost', + 'port': 5432, + 'user': 'admin', + 'password': 'password', + 'dbname': 'test_db', + 'min_connections': 1, + 'max_connections': 10, + } + logger = MagicMock() notification_handler = MagicMock() @@ -42,6 +64,8 @@ def test___init__(mock_formatters_init, mock_slot_manager_init, temporal_config=temporal_config, redis_config=redis_config, mongodb_config=mongo_db_config, + email_config=email_config, + postgres_config=postgres_config, logger=logger, notification_handler=notification_handler ) diff --git a/tests/orchestrator/activities/test_email.py b/tests/orchestrator/activities/test_email.py index e29c68e..e130c28 100644 --- a/tests/orchestrator/activities/test_email.py +++ b/tests/orchestrator/activities/test_email.py @@ -11,8 +11,8 @@ def email(smtplib, email_builder): email = Email( sender_email="test@test.com", sender_password="test", - smpt_server="test", - port=587, + smtp_server="test", + smtp_port=587, logger=MagicMock(), notification_handler=MagicMock() ) @@ -27,21 +27,22 @@ def test___init___with_password(smtplib, email_builder): email = Email( sender_email="test@test.com", sender_password="test", - smpt_server="test", - port=587, + smtp_server="test", + smtp_port=587, logger=MagicMock(), notification_handler=MagicMock() ) assert email.sender_email == "test@test.com" assert email.sender_password == "test" - assert email.port == 587 + assert email.smtp_port == 587 - smtplib.SMTP_SSL.assert_called_once_with("test", 587) - smtplib.SMTP_SSL.return_value.login.assert_called_once_with( + smtplib.SMTP.assert_called_once_with("test", 587) + smtplib.SMTP.return_value.starttls.assert_called_once() + smtplib.SMTP.return_value.login.assert_called_once_with( "test@test.com", "test") - assert email.server == smtplib.SMTP_SSL.return_value + assert email.server == smtplib.SMTP.return_value @patch('orchestrator.activities.email.EmailBuilder') @@ -50,15 +51,15 @@ def test___init___without_password(smtplib, email_builder): email = Email( sender_email="test@test.com", sender_password=None, - smpt_server="test", - port=587, + smtp_server="test", + smtp_port=587, logger=MagicMock(), notification_handler=MagicMock() ) assert email.sender_email == "test@test.com" assert email.sender_password is None - assert email.port == 587 + assert email.smtp_port == 587 smtplib.SMTP.assert_called_once_with("test", 587) assert email.server == smtplib.SMTP.return_value @@ -189,7 +190,10 @@ def test_handle_attachments_failure(mime_base, email): } ] - email.handle_attachments(attachments, message) + try: + email.handle_attachments(attachments, message) + except Exception as e: + assert str(e) == "test" assert message.attach.call_count == 0 diff --git a/tests/orchestrator/activities/test_mongo_db.py b/tests/orchestrator/activities/test_mongo_db.py index 4c4c956..e3fa686 100644 --- a/tests/orchestrator/activities/test_mongo_db.py +++ b/tests/orchestrator/activities/test_mongo_db.py @@ -555,17 +555,11 @@ async def test_load_latest_data_none_last_data_timestamp(mongo_db): {"_id": 0} ) - assert result == { - 'name': { - 0: 'test1' - }, - 'value': { - 0: 1 - }, - 'timestamp': { - 0: '2023-01-01 12:00:00.000000' - } - } + assert result == [{ + 'name': 'test1', + 'value': 1, + 'timestamp': '2023-01-01 12:00:00.000000' + }] @mark.asyncio @@ -606,17 +600,11 @@ async def test_load_latest_data_not_none_last_data_timestamp(mongo_db): {"_id": 0} ) - assert result == { - 'name': { - 0: 'test1' - }, - 'value': { - 0: 1 - }, - 'timestamp': { - 0: '2023-01-01 12:00:00.000000' - } - } + assert result == [{ + 'name': 'test1', + 'value': 1, + 'timestamp': '2023-01-01 12:00:00.000000' + }] @mark.asyncio diff --git a/tests/orchestrator/utils/test_connectors_config.py b/tests/orchestrator/utils/test_connectors_config.py index a1c219e..f1a62b9 100644 --- a/tests/orchestrator/utils/test_connectors_config.py +++ b/tests/orchestrator/utils/test_connectors_config.py @@ -1,7 +1,10 @@ from os import environ from orchestrator.utils.connectors_config import (build_redis_config, build_couchbase_config, - build_mongodb_config, build_temporal_config) + build_mongodb_config, + build_temporal_config, + build_email_config, + build_postgres_config) def test_build_redis_config_with_env_vars(): @@ -100,3 +103,69 @@ def test_build_temporal_config_with_defaults(): 'temporal_scouter_namespace': 'scouter', 'temporal_laborious_namespace': 'laborious' } + + +def test_build_email_config_with_env_vars(): + environ['EMAIL_SENDER'] = 'test@test.com' + environ['EMAIL_SENDER_PASSWORD'] = 'test' + environ['EMAIL_SMTP_SERVER'] = 'test' + environ['EMAIL_SMTP_PORT'] = '587' + assert build_email_config() == { + 'sender_email': 'test@test.com', + 'sender_password': 'test', + 'smtp_server': 'test', + 'smtp_port': 587 + } + + +def test_build_email_config_with_defaults(): + environ.pop('EMAIL_SENDER', None) + environ.pop('EMAIL_SENDER_PASSWORD', None) + environ.pop('EMAIL_SMTP_SERVER', None) + environ.pop('EMAIL_SMTP_PORT', None) + + assert build_email_config() == { + 'sender_email': 'sientia-alerts@aignosi.com', + 'sender_password': 'sientia', + 'smtp_server': 'smtp.gmail.com', + 'smtp_port': 587 + } + + +def test_build_postgres_config_with_env_vars(): + environ['POSTGRES_HOST'] = 'localhost' + environ['POSTGRES_PORT'] = '5432' + environ['POSTGRES_USER'] = 'sientia' + environ['POSTGRES_PASSWORD'] = 'sientia' + environ['POSTGRES_DBNAME'] = 'sientia' + environ['POSTGRES_MIN_CONNECTIONS'] = '5' + environ['POSTGRES_MAX_CONNECTIONS'] = '20' + assert build_postgres_config() == { + 'host': 'localhost', + 'port': 5432, + 'user': 'sientia', + 'password': 'sientia', + 'dbname': 'sientia', + 'min_connections': 5, + 'max_connections': 20 + } + + +def test_build_postgres_config_with_defaults(): + environ.pop('POSTGRES_HOST', None) + environ.pop('POSTGRES_PORT', None) + environ.pop('POSTGRES_USER', None) + environ.pop('POSTGRES_PASSWORD', None) + environ.pop('POSTGRES_DBNAME', None) + environ.pop('POSTGRES_MIN_CONNECTIONS', None) + environ.pop('POSTGRES_MAX_CONNECTIONS', None) + + assert build_postgres_config() == { + 'host': 'localhost', + 'port': 5432, + 'user': 'sientia', + 'password': 'sientia', + 'dbname': 'sientia', + 'min_connections': 5, + 'max_connections': 20 + } diff --git a/tests/orchestrator/workflows/subworkflows/test_load_notification_package.py b/tests/orchestrator/workflows/subworkflows/test_load_notification_package.py index 952cd3f..3841728 100644 --- a/tests/orchestrator/workflows/subworkflows/test_load_notification_package.py +++ b/tests/orchestrator/workflows/subworkflows/test_load_notification_package.py @@ -23,7 +23,10 @@ metadata = { @patch("orchestrator.workflows.subworkflows.load_notification_package.workflow", new_callable=AsyncMock) async def test_run(workflow_mock, load_notification_package): input_data = { - 'metadata': metadata + 'metadata': metadata, + 'base_data_filter': { + 'level': 'ERROR' + } } workflow_mock.start_local_activity_method.side_effect = [ @@ -113,3 +116,57 @@ async def test_run(workflow_mock, load_notification_package): retry_policy=ANY ) ]) + + +@mark.asyncio +@patch("orchestrator.workflows.subworkflows.load_notification_package.workflow", new_callable=AsyncMock) +async def test_run_no_data(workflow_mock, load_notification_package): + input_data = { + 'metadata': metadata, + 'base_data_filter': { + 'level': 'ERROR' + } + } + + workflow_mock.start_local_activity_method.side_effect = [ + '2023-01-01 12:00:00', + [], + [] + ] + + output = await load_notification_package.run(input_data) + + assert output == { + 'last_timestamp': '2023-01-01 12:00:00', + 'notification_package': [], + 'sending_configs': [] + } + + workflow_mock.start_activity_method.assert_not_called() + + +@mark.asyncio +@patch("orchestrator.workflows.subworkflows.load_notification_package.workflow", new_callable=AsyncMock) +async def test_run_no_data(workflow_mock, load_notification_package): + input_data = { + 'metadata': metadata, + 'base_data_filter': { + 'level': 'ERROR' + } + } + + workflow_mock.start_local_activity_method.side_effect = [ + '2023-01-01 12:00:00', + ["data"], + [] + ] + + output = await load_notification_package.run(input_data) + + assert output == { + 'last_timestamp': '2023-01-01 12:00:00', + 'notification_package': ["data"], + 'sending_configs': [] + } + + workflow_mock.start_activity_method.assert_not_called() diff --git a/tests/orchestrator/workflows/subworkflows/test_process_notifications.py b/tests/orchestrator/workflows/subworkflows/test_process_notifications.py index 4dd6913..df25c84 100644 --- a/tests/orchestrator/workflows/subworkflows/test_process_notifications.py +++ b/tests/orchestrator/workflows/subworkflows/test_process_notifications.py @@ -39,11 +39,14 @@ async def test_run(workflow_mock, process_notifications): Activities.build_email_html, { **metadata, - 'receiver_groups': input_data['notification_package'] + 'receiver_groups': input_data['notification_package'], + 'mail_type': input_data['mail_type'] }, schedule_to_close_timeout=ANY, retry_policy=ANY - ), + ) + ]) + workflow_mock.execute_local_activity_method.assert_has_calls([ call( Activities.format_log_report, { @@ -51,6 +54,8 @@ async def test_run(workflow_mock, process_notifications): 'receiver_groups': workflow_mock.execute_activity_method.return_value, 'mail_type': input_data['mail_type'] }, + schedule_to_close_timeout=ANY, + retry_policy=ANY ) ]) diff --git a/tests/orchestrator/workflows/test_alerts.py b/tests/orchestrator/workflows/test_alerts.py index 788e06d..0dfffd3 100644 --- a/tests/orchestrator/workflows/test_alerts.py +++ b/tests/orchestrator/workflows/test_alerts.py @@ -64,7 +64,9 @@ async def test_run_full_flow(workflow_mock, alerts): 'notification_package': workflow_mock.execute_child_workflow.return_value['notification_package'], 'sending_configs': workflow_mock.execute_child_workflow.return_value['sending_configs'], 'notification_ttl': input_data['notification_ttl'] - } + }, + schedule_to_close_timeout=ANY, + retry_policy=ANY ) ]) @@ -75,6 +77,41 @@ async def test_run_full_flow(workflow_mock, alerts): **metadata, 'log_report': workflow_mock.execute_child_workflow.return_value, 'sent_ttl': input_data['sent_ttl'] + }, + schedule_to_close_timeout=ANY, + retry_policy=ANY + ) + ]) + + +@mark.asyncio +@patch("orchestrator.workflows.alerts.workflow", new_callable=AsyncMock) +async def test_run_no_data(workflow_mock, alerts): + workflow_mock.execute_child_workflow.return_value = { + 'last_timestamp': '2023-01-01 12:00:00.000000', + 'notification_package': [], + 'sending_configs': [] + } + + input_data = { + 'schedule_name': 'test-schedule-name', + 'notification_ttl': 300, + 'sent_ttl': 600 + } + + await alerts.run(input_data) + + workflow_mock.execute_child_workflow.assert_has_calls([ + call( + 'load_notification_package', + { + **input_data, + 'metadata': metadata, + 'base_data_filter': { + 'level': 'ERROR' + } } ) ]) + + workflow_mock.execute_local_activity_method.assert_not_called() From 6f6094b2ce08f0cad1a9c5fb57f876345d345419 Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Tue, 29 Jul 2025 11:42:05 -0300 Subject: [PATCH 07/14] SIENTIAPDE-1172 feat: enhance email notifications and orchestrator activities for reports - Updated email HTML structure to clearly present errors, warnings, and info notifications. - Introduced a new method in the Formatters class to filter notification reports based on group configurations. - Enhanced the Reports workflow to integrate the new filtering functionality and manage notification packages effectively. - Adjusted SlotManager to store timestamps with mail type specificity for better tracking. - Improved test coverage for the new filtering functionality in the Formatters class. --- email.html | 246 +++++++++++++++++- orchestrator/activities/formatters.py | 37 ++- orchestrator/activities/slot_manager.py | 4 +- orchestrator/worker/worker.py | 22 ++ orchestrator/workflows/alerts.py | 1 + orchestrator/workflows/reports.py | 65 ++++- .../subworkflows/load_notification_package.py | 5 +- .../activities/test_formatters.py | 58 +++++ tests/orchestrator/workflows/test_reports.py | 99 +++++++ 9 files changed, 527 insertions(+), 10 deletions(-) create mode 100644 tests/orchestrator/workflows/test_reports.py diff --git a/email.html b/email.html index c75e731..5e90b26 100644 --- a/email.html +++ b/email.html @@ -15,11 +15,251 @@

SIENTIA™ Alerts

-

+

Errors detected:

-

+

Model: ipsum et

+ + + + + + + + + + + + + + + + + + + + + +
Notification IDScheduleBlockTimestampMessage
TAG_node34:tag_8_LISTENNING_STOPPEDadipiscing_eiusmod_dodolor_labore_consectetur_ipsum2025-07-29 14:39:52.952316+00:00sed elit dolore incididunt incididunt aliqua
-

+

Model: incididunt et do

+ + + + + + + + + + + + + + + + + + + + + +
Notification IDScheduleBlockTimestampMessage
OPC_LISTENNING_STOPPED__server_5eiusmod_adipiscing_dolore_ipsum_incididunt_incididuntaliqua_dolore2025-07-29 14:39:52.952579+00:00dolor elit do ipsum consectetur amet ut do amet et
+ +

Model: magna adipiscing aliqua

+ + + + + + + + + + + + + + + + + + + + + +
Notification IDScheduleBlockTimestampMessage
REPORT_PARTITION_MANAGERdo_dolore_ut_ametet_eiusmod2025-07-29 14:39:52.952688+00:00incididunt lorem eiusmod do et ipsum et ut
+ +

Warnings detected:

+ +

Model: adipiscing magna lorem

+ + + + + + + + + + + + + + + + + + + + + +
Notification IDScheduleBlockTimestampMessage
REPORT_PARTITION_MANAGERipsum_et_incididunt_dout_sed_incididunt2025-07-29 14:39:52.952121+00:00aliqua tempor aliqua sit ipsum amet elit ipsum
+ +

Model: sed amet adipiscing incididunt

+ + + + + + + + + + + + + + + + + + + + + +
Notification IDScheduleBlockTimestampMessage
ALIQUA_SIT_INCIDIDUNT_EIUSMODconsectetur_dolore_sit_sed_seddolore_ipsum2025-07-29 14:39:52.952259+00:00do aliqua ut incididunt consectetur consectetur sed et labore
+ +

Model: ipsum ipsum amet tempor

+ + + + + + + + + + + + + + + + + + + + + +
Notification IDScheduleBlockTimestampMessage
OPC_LISTENNING_STOPPED__server_6sit_labore_sed_dolor_et_elitdolor_do_magna_et2025-07-29 14:39:52.952376+00:00magna consectetur do et dolor aliqua
+ +

Model: sed ut

+ + + + + + + + + + + + + + + + + + + + + +
Notification IDScheduleBlockTimestampMessage
REPORT_PARTITION_MANAGERelit_et_ipsum_doloresed_ipsum2025-07-29 14:39:52.952426+00:00elit dolor dolore dolor eiusmod
+ +

Model: eiusmod labore

+ + + + + + + + + + + + + + + + + + + + + +
Notification IDScheduleBlockTimestampMessage
OPC_CONNECTION_RETRY__server_3dolor_ut_dolor_sit_adipiscing_incididuntsit_tempor_dolore2025-07-29 14:39:52.952483+00:00ipsum consectetur magna elit dolore
+ +

Model: do dolor tempor amet

+ + + + + + + + + + + + + + + + + + + + + +
Notification IDScheduleBlockTimestampMessage
REPORT_PARTITION_MANAGERsed_sit_do_tempor_uteiusmod_adipiscing2025-07-29 14:39:52.952637+00:00incididunt do do adipiscing dolore ut ut elit sit labore
+ +

Infos detected:

+ +

Model: sed consectetur ut

+ + + + + + + + + + + + + + + + + + + + + +
Notification IDScheduleBlockTimestampMessage
TEMPOR_DO_DOLOR_TEMPOR_DOLORelit_tempor_dolore_consectetur_dolore_adipiscingmagna_labore_lorem2025-07-29 14:39:52.952199+00:00et et adipiscing lorem magna eiusmod labore do
diff --git a/orchestrator/activities/formatters.py b/orchestrator/activities/formatters.py index 635c613..17a091b 100644 --- a/orchestrator/activities/formatters.py +++ b/orchestrator/activities/formatters.py @@ -532,6 +532,41 @@ class Formatters(BaseActivity): 'mail_type': mail_type } else: - data[key]['groups'].append(group_name) + if group_name not in data[key]['groups']: + data[key]['groups'].append(group_name) return DataFrame(list(data.values())).to_dict() + + @activity.defn(name="filter_notification_reports") + async def filter_notification_reports(self, input_data: dict[str, Any]) -> dict[str, Any]: + """ + Filter notification reports. + """ + metadata = input_data['metadata'] + notification_package = input_data['notification_package'] + sending_configs = input_data['sending_configs'] + + self.info("Filtering notification reports...", metadata=metadata) + + receiver_groups = {} + + for receiver_group in sending_configs: + group_name = receiver_group['group_name'] + receiver_groups[group_name] = { + **receiver_group, + "notifications": [] + } + receiver_groups[group_name]['notifications'] = [] + + ignore_list = receiver_group.get('ignore', []) + + for notification in notification_package: + alert_type = "reports" + notification_id = notification['notification_id'] + + # Check if this group must be notified + if alert_type in receiver_group['contents'] and notification_id not in ignore_list: + receiver_groups[group_name]["notifications"].append( + notification) + + return receiver_groups diff --git a/orchestrator/activities/slot_manager.py b/orchestrator/activities/slot_manager.py index 17dbd07..16cf26f 100644 --- a/orchestrator/activities/slot_manager.py +++ b/orchestrator/activities/slot_manager.py @@ -204,7 +204,7 @@ class SlotManager(Redis): Gets the last data timestamp from redis. """ metadata = input_data['metadata'] - key = "notification_last_timestamp" + key = f"notification_last_timestamp:{input_data['mail_type']}" try: data_hold = self.get(key) @@ -235,7 +235,7 @@ class SlotManager(Redis): Puts the last data timestamp into redis. """ metadata = input_data['metadata'] - key = "notification_last_timestamp" + key = f"notification_last_timestamp:{input_data['mail_type']}" data = DataFrame(input_data['data']) diff --git a/orchestrator/worker/worker.py b/orchestrator/worker/worker.py index 21f9e96..fcd815d 100644 --- a/orchestrator/worker/worker.py +++ b/orchestrator/worker/worker.py @@ -7,6 +7,7 @@ with workflow.unsafe.imports_passed_through(): import sys import asyncio from orchestrator.workflows.alerts import Alerts + from orchestrator.workflows.reports import Reports from orchestrator.workflows.subworkflows.load_notification_package import LoadNotificationPackage from orchestrator.workflows.subworkflows.process_notifications import ProcessNotifications from orchestrator.workflows.orchestrator import Orchestrator @@ -121,6 +122,27 @@ async def main(): # Store notification cache activities.store_notification_cache ] + ), + Worker( + temporal_client, + task_queue='reports-queue', + workflows=[Reports, LoadNotificationPackage, ProcessNotifications], + activities=[ + # Load notifications + activities.get_last_data_timestamp, + activities.find_documents_in_mongodb, + activities.load_latest_data, + activities.put_last_data_timestamp, + + # Format and filter notifications + activities.filter_notification_reports, + + # Send email and export data to postgres + activities.build_email_html, + activities.send_email, + activities.format_log_report, + activities.export_data_to_postgres + ] ) ] diff --git a/orchestrator/workflows/alerts.py b/orchestrator/workflows/alerts.py index 441390b..6fbe231 100644 --- a/orchestrator/workflows/alerts.py +++ b/orchestrator/workflows/alerts.py @@ -38,6 +38,7 @@ class Alerts: mail_type = "Alerts" input_data['metadata'] = metadata + input_data['mail_type'] = mail_type input_data['base_data_filter'] = { 'level': 'ERROR' diff --git a/orchestrator/workflows/reports.py b/orchestrator/workflows/reports.py index 002034f..75e2851 100644 --- a/orchestrator/workflows/reports.py +++ b/orchestrator/workflows/reports.py @@ -3,17 +3,76 @@ from temporalio import workflow with workflow.unsafe.imports_passed_through(): from orchestrator.activities.activities import Activities from typing import Any + from datetime import timedelta + from sientia_do.temporal.utils.policies import retry_policy @workflow.defn(name="reports") class Reports: @workflow.run async def run(self, input_data: dict[str, Any]): + """ + Workflow to send reports to the users + + Args: + input_data (dict[str, Any]): Input data. It contains the following keys: + - schedule_name: str - Name of the schedule + + Returns: + None + + Raises: + Exception: If the workflow fails + """ + metadata = { + 'metadata': { + 'schedule_name': input_data['schedule_name'], + 'workflow_name': 'reports', + 'model_name': '-', + 'model_id': '-' + } + } + + mail_type = "Reports" + + input_data['metadata'] = metadata + input_data['mail_type'] = mail_type + + input_data['base_data_filter'] = {} + # Call subworkflow "load_notification_package" passing the static filters # (timestamp > last timestamp) - # Filter notification package by groups custom configs + package = await workflow.execute_child_workflow( + 'load_notification_package', + input_data + ) + + if not package['notification_package'] or not package['sending_configs']: + return + + # Filter notification package by groups custom configs, levels and + # timestamp cached + + receiver_groups = await workflow.execute_local_activity_method( + Activities.filter_notification_reports, + { + **metadata, + 'notification_package': package['notification_package'], + 'sending_configs': package['sending_configs'] + }, + schedule_to_close_timeout=timedelta(seconds=60), + retry_policy=retry_policy + ) # Call subworkflow "process_notifications" passing the notification package - - pass + await workflow.execute_child_workflow( + 'process_notifications', + { + 'metadata': metadata, + 'mail_type': mail_type, + 'notification_package': receiver_groups, + 'schema': 'sientia_data', + 'table_name': 'log_report' + } + ) diff --git a/orchestrator/workflows/subworkflows/load_notification_package.py b/orchestrator/workflows/subworkflows/load_notification_package.py index 83a1c0f..15d3560 100644 --- a/orchestrator/workflows/subworkflows/load_notification_package.py +++ b/orchestrator/workflows/subworkflows/load_notification_package.py @@ -22,6 +22,7 @@ class LoadNotificationPackage: - last_timestamp (str): The last timestamp of the notification package. - notification_package (list[dict]): The notification package. - sending_configs (list[dict]): The sending configs. + - mail_type (str): The mail type. """ metadata = input_data['metadata'] @@ -30,6 +31,7 @@ class LoadNotificationPackage: Activities.get_last_data_timestamp, { **metadata, + 'mail_type': input_data['mail_type'] }, start_to_close_timeout=timedelta(seconds=60), retry_policy=retry_policy @@ -82,7 +84,8 @@ class LoadNotificationPackage: Activities.put_last_data_timestamp, { **metadata, - 'data': notification_package + 'data': notification_package, + 'mail_type': input_data['mail_type'] }, start_to_close_timeout=timedelta(seconds=60), retry_policy=retry_policy diff --git a/tests/orchestrator/activities/test_formatters.py b/tests/orchestrator/activities/test_formatters.py index 8440e19..ef89e9b 100644 --- a/tests/orchestrator/activities/test_formatters.py +++ b/tests/orchestrator/activities/test_formatters.py @@ -765,3 +765,61 @@ async def test_format_log_report(formatters): ) assert DataFrame(result).equals(expected_result) + + +@mark.asyncio +async def test_filter_notification_reports(formatters): + + input_data = { + **metadata, + 'notification_package': [ + { + 'trigger': 'test_trigger_1', + 'notification_id': 'test_notification_id_1' + }, + { + 'trigger': 'test_trigger_2', + 'notification_id': 'test_notification_id_2' + }, + { + 'trigger': 'test_trigger_3', + 'notification_id': 'test_notification_id_3' + } + ], + 'sending_configs': [ + { + 'group_name': 'test_group_1', + 'contents': ['reports'], + 'ignore': ['test_notification_id_1'] + }, + { + 'group_name': 'test_group_2', + 'contents': ['core_alerts'] + } + ] + } + + response = await formatters.filter_notification_reports(input_data) + + assert response == { + 'test_group_1': { + 'group_name': 'test_group_1', + 'contents': ['reports'], + 'ignore': ['test_notification_id_1'], + 'notifications': [ + { + 'trigger': 'test_trigger_2', + 'notification_id': 'test_notification_id_2' + }, + { + 'trigger': 'test_trigger_3', + 'notification_id': 'test_notification_id_3' + } + ] + }, + 'test_group_2': { + 'group_name': 'test_group_2', + 'contents': ['core_alerts'], + 'notifications': [] + } + } diff --git a/tests/orchestrator/workflows/test_reports.py b/tests/orchestrator/workflows/test_reports.py new file mode 100644 index 0000000..1b1983d --- /dev/null +++ b/tests/orchestrator/workflows/test_reports.py @@ -0,0 +1,99 @@ +from unittest.mock import AsyncMock, patch, ANY, call +from pytest import fixture, mark +from orchestrator.workflows.reports import Reports +from orchestrator.activities.activities import Activities + + +@fixture +def reports(): + return Reports() + + +metadata = { + 'metadata': { + 'schedule_name': 'test-schedule-name', + 'workflow_name': 'reports', + 'model_name': '-', + 'model_id': '-', + } +} + + +@mark.asyncio +@patch("orchestrator.workflows.reports.workflow", new_callable=AsyncMock) +async def test_run_full_flow(workflow_mock, reports): + input_data = { + 'schedule_name': 'test-schedule-name', + 'notification_ttl': 300, + 'sent_ttl': 600 + } + + await reports.run(input_data) + + workflow_mock.execute_child_workflow.assert_has_calls([ + call( + 'load_notification_package', + { + **input_data, + 'metadata': metadata, + 'base_data_filter': {} + } + ) + ]) + + workflow_mock.execute_child_workflow.assert_has_calls([ + call( + 'process_notifications', + { + 'metadata': metadata, + 'mail_type': 'Reports', + 'notification_package': workflow_mock.execute_local_activity_method.return_value, + 'schema': 'sientia_data', + 'table_name': 'log_report' + } + ) + ]) + + workflow_mock.execute_local_activity_method.assert_has_calls([ + call( + Activities.filter_notification_reports, + { + **metadata, + 'notification_package': workflow_mock.execute_child_workflow.return_value['notification_package'], + 'sending_configs': workflow_mock.execute_child_workflow.return_value['sending_configs'] + }, + schedule_to_close_timeout=ANY, + retry_policy=ANY + ) + ]) + + +@mark.asyncio +@patch("orchestrator.workflows.reports.workflow", new_callable=AsyncMock) +async def test_run_no_data(workflow_mock, reports): + workflow_mock.execute_child_workflow.return_value = { + 'last_timestamp': '2023-01-01 12:00:00.000000', + 'notification_package': [], + 'sending_configs': [] + } + + input_data = { + 'schedule_name': 'test-schedule-name', + 'notification_ttl': 300, + 'sent_ttl': 600 + } + + await reports.run(input_data) + + workflow_mock.execute_child_workflow.assert_has_calls([ + call( + 'load_notification_package', + { + **input_data, + 'metadata': metadata, + 'base_data_filter': {} + } + ) + ]) + + workflow_mock.execute_local_activity_method.assert_not_called() From 263410b292f6d98839366edf72c57967e7c2358c Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Tue, 29 Jul 2025 12:13:08 -0300 Subject: [PATCH 08/14] SIENTIAPDE-1172 feat: enhance notification handling in Formatters and SlotManager classes - Introduced a mechanism to prevent duplicate notifications by tracking already added keys. - Updated notification filtering logic to ensure unique notifications are processed for each receiver group. - Improved overall notification management in orchestrator activities. --- orchestrator/activities/formatters.py | 7 ++++++- orchestrator/activities/slot_manager.py | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/orchestrator/activities/formatters.py b/orchestrator/activities/formatters.py index 17a091b..1ebcc6b 100644 --- a/orchestrator/activities/formatters.py +++ b/orchestrator/activities/formatters.py @@ -558,15 +558,20 @@ class Formatters(BaseActivity): } receiver_groups[group_name]['notifications'] = [] + already_added_keys = [] + ignore_list = receiver_group.get('ignore', []) for notification in notification_package: alert_type = "reports" notification_id = notification['notification_id'] + key = f"{notification['trigger']}:{notification_id}" + # Check if this group must be notified - if alert_type in receiver_group['contents'] and notification_id not in ignore_list: + if alert_type in receiver_group['contents'] and notification_id not in ignore_list and key not in already_added_keys: receiver_groups[group_name]["notifications"].append( notification) + already_added_keys.append(key) return receiver_groups diff --git a/orchestrator/activities/slot_manager.py b/orchestrator/activities/slot_manager.py index 16cf26f..076a1a8 100644 --- a/orchestrator/activities/slot_manager.py +++ b/orchestrator/activities/slot_manager.py @@ -289,6 +289,8 @@ class SlotManager(Redis): } receiver_groups[group_name]['notifications'] = [] + already_added_keys = [] + ignore_list = receiver_group.get('ignore', []) for notification in notification_package: @@ -311,9 +313,10 @@ class SlotManager(Redis): alert_type = "persistent_alerts" # Check if this group must be notified - if alert_type in receiver_group['contents'] and notification_id not in ignore_list: + if alert_type in receiver_group['contents'] and notification_id not in ignore_list and key not in already_added_keys: receiver_groups[group_name]["notifications"].append( notification) + already_added_keys.append(key) return receiver_groups From dd22e8a553912e4be0710d1dc0c9f746b0c2e796 Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Tue, 29 Jul 2025 12:50:03 -0300 Subject: [PATCH 09/14] SIENTIAPDE-1172 Update values.yaml and tests to incorporate mail_type parameter for notification handling - Changed GITHUB_BRANCH in values.yaml to reflect the new alert pipeline. - Added 'mail_type' parameter to various test cases in SlotManager and notification workflows to enhance notification specificity and tracking. - Ensured consistency in test data structures across multiple test functions. --- .../activities/test_slot_manager.py | 25 ++++++++++++------- .../test_load_notification_package.py | 13 +++++++--- values.yaml | 4 +-- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/tests/orchestrator/activities/test_slot_manager.py b/tests/orchestrator/activities/test_slot_manager.py index d43112c..debf0a7 100644 --- a/tests/orchestrator/activities/test_slot_manager.py +++ b/tests/orchestrator/activities/test_slot_manager.py @@ -217,7 +217,8 @@ async def test_get_last_data_timestamp_none(slot_manager): test_data = { **metadata, 'workflow_name': 'test_pipeline', - 'schedule_name': 'test_schedule' + 'schedule_name': 'test_schedule', + 'mail_type': 'test_mail_type' } slot_manager.get = MagicMock(return_value=None) @@ -233,7 +234,8 @@ async def test_get_last_data_timestamp_not_none(slot_manager): test_data = { **metadata, 'workflow_name': 'test_pipeline', - 'schedule_name': 'test_schedule' + 'schedule_name': 'test_schedule', + 'mail_type': 'test_mail_type' } slot_manager.get = MagicMock(return_value='2023-01-01 12:00:00') @@ -241,7 +243,7 @@ async def test_get_last_data_timestamp_not_none(slot_manager): result = await slot_manager.get_last_data_timestamp(test_data) slot_manager.get.assert_called_once_with( - 'notification_last_timestamp' + 'notification_last_timestamp:test_mail_type' ) assert result == '2023-01-01 12:00:00' @@ -253,7 +255,8 @@ async def test_get_last_data_timestamp_error(slot_manager): test_data = { **metadata, 'workflow_name': 'test_pipeline', - 'schedule_name': 'test_schedule' + 'schedule_name': 'test_schedule', + 'mail_type': 'test_mail_type' } slot_manager.send_notification = MagicMock() @@ -286,7 +289,8 @@ async def test_put_last_data_timestamp_empty_dataframe(slot_manager): **metadata, 'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule', - 'data': DataFrame(columns=['name', 'value', 'timestamp']).to_dict('records') + 'data': DataFrame(columns=['name', 'value', 'timestamp']).to_dict('records'), + 'mail_type': 'test_mail_type' } slot_manager.set = MagicMock() @@ -311,7 +315,8 @@ async def test_put_last_data_timestamp_not_empty_dataframe(slot_manager): **metadata, 'workflow_name': 'test_pipeline', 'schedule_name': 'test_schedule', - 'data': data.to_dict('records') + 'data': data.to_dict('records'), + 'mail_type': 'test_mail_type' } slot_manager.set = MagicMock() @@ -321,7 +326,7 @@ async def test_put_last_data_timestamp_not_empty_dataframe(slot_manager): assert result == '2023-01-01 12:00:01' slot_manager.set.assert_called_once_with( - 'notification_last_timestamp', + 'notification_last_timestamp:test_mail_type', '2023-01-01 12:00:01', ttl=18000 ) @@ -338,7 +343,8 @@ async def test_put_last_data_timestamp_error(slot_manager): 'name': ['sensor1', 'sensor2'], 'value': [25.5, 30.0], 'timestamp': ['2023-01-01 12:00:00'] * 2 - }).to_dict('records') + }).to_dict('records'), + 'mail_type': 'test_mail_type' } slot_manager.send_notification = MagicMock() @@ -401,7 +407,8 @@ async def test_filter_notification_alerts(slot_manager): 'contents': ['core_alerts'] } ], - 'notification_ttl': 300 + 'notification_ttl': 300, + 'mail_type': 'test_mail_type' } response = await slot_manager.filter_notification_alerts(input_data) diff --git a/tests/orchestrator/workflows/subworkflows/test_load_notification_package.py b/tests/orchestrator/workflows/subworkflows/test_load_notification_package.py index 3841728..dc77662 100644 --- a/tests/orchestrator/workflows/subworkflows/test_load_notification_package.py +++ b/tests/orchestrator/workflows/subworkflows/test_load_notification_package.py @@ -24,6 +24,7 @@ metadata = { async def test_run(workflow_mock, load_notification_package): input_data = { 'metadata': metadata, + 'mail_type': 'test_mail_type', 'base_data_filter': { 'level': 'ERROR' } @@ -62,7 +63,10 @@ async def test_run(workflow_mock, load_notification_package): workflow_mock.start_local_activity_method.assert_has_calls([ call( Activities.get_last_data_timestamp, - input_data['metadata'], + { + **input_data['metadata'], + 'mail_type': 'test_mail_type' + }, start_to_close_timeout=ANY, retry_policy=ANY ) @@ -110,7 +114,8 @@ async def test_run(workflow_mock, load_notification_package): { 'id': '1', } - ] + ], + 'mail_type': 'test_mail_type' }, start_to_close_timeout=ANY, retry_policy=ANY @@ -123,6 +128,7 @@ async def test_run(workflow_mock, load_notification_package): async def test_run_no_data(workflow_mock, load_notification_package): input_data = { 'metadata': metadata, + 'mail_type': 'test_mail_type', 'base_data_filter': { 'level': 'ERROR' } @@ -152,7 +158,8 @@ async def test_run_no_data(workflow_mock, load_notification_package): 'metadata': metadata, 'base_data_filter': { 'level': 'ERROR' - } + }, + 'mail_type': 'test_mail_type' } workflow_mock.start_local_activity_method.side_effect = [ diff --git a/values.yaml b/values.yaml index 05b3754..0453d96 100644 --- a/values.yaml +++ b/values.yaml @@ -132,7 +132,7 @@ env: - name: GITHUB_REPO_URL value: "git@github.com:Aignosi/sientia-dataops-orchestrator_temporal.git" - name: GITHUB_BRANCH - value: "SIENTIAPDE-1171-criar-pipeline-de-retreino-laborious" + value: "SIENTIAPDE-1172-criar-pipeline-de-alertas-orquestrador" - name: PYTHON_APP value: "orchestrator.worker.worker" @@ -216,4 +216,4 @@ ssh: # kubectl create secret generic smtp-credentials \ # --namespace sientia \ -# --from-literal=app_password='sua-senha-de-app-de-16-digitos' \ No newline at end of file +# --from-literal=app_password='sua-senha-de-app-de-16-digitos' From 0cda2bcb057b2d58520afaa2f008faf1707b7acb Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Tue, 29 Jul 2025 13:19:54 -0300 Subject: [PATCH 10/14] SIENTIAPDE-1172 Update dependencies in requirements.txt and increment image tag in values.yaml - Updated sientia-dataops-library dependency version from 1.3.4 to 1.3.5. - Changed image tag in values.yaml from 0.2.5 to 0.2.7 for the latest image version. --- requirements.txt | 2 +- values.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index d06bf0e..166f96d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,4 @@ redis couchbase pymongo jinja2 -git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.3.4 +git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.3.5 diff --git a/values.yaml b/values.yaml index 0453d96..a9502de 100644 --- a/values.yaml +++ b/values.yaml @@ -11,7 +11,7 @@ image: # This sets the pull policy for images. pullPolicy: Always # Overrides the image tag whose default is the chart appVersion. - tag: "0.2.5" + tag: "0.2.7" # This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ imagePullSecrets: From 0a5e137cc3589df98718005241007d3574b5e31e Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Wed, 30 Jul 2025 08:29:11 -0300 Subject: [PATCH 11/14] SIENTIAPDE-1172 SIENTIAPDE-1172 feat: update email configuration and enhance email sending functionality - Changed EMAIL_SENDER in values.yaml to a new email address. - Updated EMAIL_SENDER_PASSWORD key reference in values.yaml for improved security. - Added PostgreSQL configuration parameters to values.yaml for database connectivity. - Refactored Email class to include a new send_email method for better error handling during email transmission. - Implemented reconnection logic for SMTP server disconnections to ensure reliable email delivery. --- orchestrator/activities/email.py | 32 ++++++++++++++++++++++++++++++-- values.yaml | 20 ++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/orchestrator/activities/email.py b/orchestrator/activities/email.py index e85ee30..3030e91 100644 --- a/orchestrator/activities/email.py +++ b/orchestrator/activities/email.py @@ -1,4 +1,5 @@ +from smtplib import SMTPServerDisconnected from temporalio import workflow, activity with workflow.unsafe.imports_passed_through(): @@ -25,6 +26,7 @@ class Email(BaseActivity): self.sender_email = sender_email self.sender_password = sender_password self.smtp_port = smtp_port + self.smtp_server = smtp_server logger.info(f"Initializing Email with {smtp_server}:{smtp_port}") @@ -99,6 +101,33 @@ class Email(BaseActivity): return msg + def send_email(self, msg: MIMEMultipart, receivers: list[str]): + """ + Sends an email to the receivers. + """ + + try: + self.server.sendmail( + self.sender_email, receivers, msg.as_string()) + except SMTPServerDisconnected as e: + self.logger.error(f"SMTP server disconnected: {e}") + self.logger.info( + f"Reconnecting to {self.smtp_server}:{self.smtp_port}") + + if self.server: + try: + self.server.quit() + except SMTPServerDisconnected as e: + self.logger.info(f"Server already disconnected: {e}") + except Exception as e: + self.logger.error(f"Failed to quit server: {e}") + raise e + + self.server = smtplib.SMTP(self.smtp_server, self.smtp_port) + self.server.starttls() + self.server.login(self.sender_email, self.sender_password) + self.server.sendmail(self.sender_email, receivers, msg.as_string()) + @activity.defn(name="send_email") async def send_email(self, input_data: dict[str, Any]) -> dict[str, Any]: """ @@ -138,8 +167,7 @@ class Email(BaseActivity): if notification.get('attachment_content') is not None], msg) - self.server.sendmail( - self.sender_email, receivers, msg.as_string()) + self.send_email(msg, receivers) except Exception as e: self.error(f"Failed to send email to {group_name}: {e}", metadata=metadata) diff --git a/values.yaml b/values.yaml index a9502de..570ae29 100644 --- a/values.yaml +++ b/values.yaml @@ -171,17 +171,33 @@ env: value: "1" - name: EMAIL_SENDER - value: "sientia-alerts@aignosi.com" + value: "vitor.santos@aignosi.com.br" - name: EMAIL_SENDER_PASSWORD valueFrom: secretKeyRef: name: smtp-credentials - key: sender-password + key: app_password - name: EMAIL_SMTP_SERVER value: "smtp.gmail.com" - name: EMAIL_SMTP_PORT value: "587" + # Application variables + - name: POSTGRES_HOST + value: "paradedb-rw.paradedb.svc.cluster.local" + - name: POSTGRES_PORT + value: "5432" + - name: POSTGRES_USER + value: "sientia" + - name: POSTGRES_PASSWORD + value: "sientia" + - name: POSTGRES_DBNAME + value: "sientia" + - name: POSTGRES_MIN_CONNECTIONS + value: "10" + - name: POSTGRES_MAX_CONNECTIONS + value: "40" + - name: KAFKA_BOOTSTRAP_SERVERS value: "kafka.kafka.svc.cluster.local:9092" From e75e089b1bf0e443adb8343b36b3b135256a454d Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Wed, 30 Jul 2025 08:43:48 -0300 Subject: [PATCH 12/14] SIENTIAPDE-1172 refactor: rename send_email method to try_send_email and update parameter type - Renamed the send_email method to try_send_email for clarity in its purpose. - Changed the receivers parameter type from list[str] to str to simplify the method signature. --- orchestrator/activities/email.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/orchestrator/activities/email.py b/orchestrator/activities/email.py index 3030e91..2542066 100644 --- a/orchestrator/activities/email.py +++ b/orchestrator/activities/email.py @@ -101,7 +101,7 @@ class Email(BaseActivity): return msg - def send_email(self, msg: MIMEMultipart, receivers: list[str]): + def try_send_email(self, msg: MIMEMultipart, receivers: str): """ Sends an email to the receivers. """ @@ -167,7 +167,7 @@ class Email(BaseActivity): if notification.get('attachment_content') is not None], msg) - self.send_email(msg, receivers) + self.try_send_email(msg, receivers) except Exception as e: self.error(f"Failed to send email to {group_name}: {e}", metadata=metadata) From ce380bd46d988080af98f43d3138425cd4946177 Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Wed, 30 Jul 2025 08:55:59 -0300 Subject: [PATCH 13/14] SIENTIAPDE-1172 fix: add timeout to SMTP connection for improved reliability - Updated the SMTP connection in the Email class to include a timeout of 20 seconds, enhancing the robustness of email sending operations. - Adjusted the reconnection logic to maintain consistent timeout settings during server initialization. --- orchestrator/activities/email.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/orchestrator/activities/email.py b/orchestrator/activities/email.py index 2542066..c5c4400 100644 --- a/orchestrator/activities/email.py +++ b/orchestrator/activities/email.py @@ -30,7 +30,7 @@ class Email(BaseActivity): logger.info(f"Initializing Email with {smtp_server}:{smtp_port}") - self.server = smtplib.SMTP(smtp_server, smtp_port) + self.server = smtplib.SMTP(smtp_server, smtp_port, timeout=20) if self.sender_password: self.server.starttls() @@ -123,7 +123,8 @@ class Email(BaseActivity): self.logger.error(f"Failed to quit server: {e}") raise e - self.server = smtplib.SMTP(self.smtp_server, self.smtp_port) + self.server = smtplib.SMTP( + self.smtp_server, self.smtp_port, timeout=20) self.server.starttls() self.server.login(self.sender_email, self.sender_password) self.server.sendmail(self.sender_email, receivers, msg.as_string()) From 3a8eb0446269f502036d1c6c63887ed73ec5a031 Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Wed, 30 Jul 2025 09:25:02 -0300 Subject: [PATCH 14/14] SIENTIAPDE-1172 fix: add conditional login for SMTP server and enhance test coverage - Updated the Email class to conditionally log in to the SMTP server only if a sender password is provided, improving security. - Enhanced test cases for the try_send_email method to cover various scenarios, including successful email sending and handling SMTP disconnections. --- orchestrator/activities/email.py | 5 +- tests/orchestrator/activities/test_email.py | 94 +++++++++++++++++++-- 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/orchestrator/activities/email.py b/orchestrator/activities/email.py index c5c4400..b59ff91 100644 --- a/orchestrator/activities/email.py +++ b/orchestrator/activities/email.py @@ -125,8 +125,9 @@ class Email(BaseActivity): self.server = smtplib.SMTP( self.smtp_server, self.smtp_port, timeout=20) - self.server.starttls() - self.server.login(self.sender_email, self.sender_password) + if self.sender_password: + self.server.starttls() + self.server.login(self.sender_email, self.sender_password) self.server.sendmail(self.sender_email, receivers, msg.as_string()) @activity.defn(name="send_email") diff --git a/tests/orchestrator/activities/test_email.py b/tests/orchestrator/activities/test_email.py index e130c28..1c82145 100644 --- a/tests/orchestrator/activities/test_email.py +++ b/tests/orchestrator/activities/test_email.py @@ -1,3 +1,4 @@ +from smtplib import SMTPServerDisconnected from unittest.mock import MagicMock, call, patch from pytest import mark, fixture @@ -37,7 +38,7 @@ def test___init___with_password(smtplib, email_builder): assert email.sender_password == "test" assert email.smtp_port == 587 - smtplib.SMTP.assert_called_once_with("test", 587) + smtplib.SMTP.assert_called_once_with("test", 587, timeout=20) smtplib.SMTP.return_value.starttls.assert_called_once() smtplib.SMTP.return_value.login.assert_called_once_with( "test@test.com", "test") @@ -61,7 +62,7 @@ def test___init___without_password(smtplib, email_builder): assert email.sender_password is None assert email.smtp_port == 587 - smtplib.SMTP.assert_called_once_with("test", 587) + smtplib.SMTP.assert_called_once_with("test", 587, timeout=20) assert email.server == smtplib.SMTP.return_value @@ -198,6 +199,83 @@ def test_handle_attachments_failure(mime_base, email): assert message.attach.call_count == 0 +def test_try_send_email_success(email): + email.server.sendmail = MagicMock() + + msg = MagicMock() + + email.try_send_email(msg, "test") + + email.server.sendmail.assert_called_once_with( + "test@test.com", "test", msg.as_string.return_value) + + +@patch('orchestrator.activities.email.smtplib.SMTP') +def test_try_send_email_reconnect_quit_success(smtp, email): + email.server.sendmail = MagicMock( + side_effect=SMTPServerDisconnected("test") + ) + email.server.quit = MagicMock() + + msg = MagicMock() + + email.try_send_email(msg, "test") + + smtp.assert_has_calls([ + call("test", 587, timeout=20) + ]) + + smtp.return_value.starttls.assert_called_once() + smtp.return_value.login.assert_called_once_with( + "test@test.com", "test") + + smtp.return_value.sendmail.assert_called_once_with( + "test@test.com", "test", msg.as_string.return_value) + + +@patch('orchestrator.activities.email.smtplib.SMTP') +def test_try_send_email_reconnect_quit_failure_disconnect(smtp, email): + email.server.sendmail = MagicMock( + side_effect=SMTPServerDisconnected("test") + ) + email.server.quit = MagicMock( + side_effect=SMTPServerDisconnected("test") + ) + + msg = MagicMock() + + email.try_send_email(msg, "test") + + smtp.assert_has_calls([ + call("test", 587, timeout=20) + ]) + smtp.return_value.starttls.assert_called_once() + smtp.return_value.login.assert_called_once_with( + "test@test.com", "test") + + smtp.return_value.sendmail.assert_called_once_with( + "test@test.com", "test", msg.as_string.return_value) + + +@patch('orchestrator.activities.email.smtplib.SMTP') +def test_try_send_email_reconnect_quit_failure(smtp, email): + email.server.sendmail = MagicMock( + side_effect=SMTPServerDisconnected("test") + ) + email.server.quit = MagicMock( + side_effect=Exception("test") + ) + + msg = MagicMock() + + try: + email.try_send_email(msg, "test") + except Exception as e: + assert str(e) == "test" + else: + assert False, "Expected exception" + + @mark.asyncio @patch('orchestrator.activities.email.MIMEText') @patch('orchestrator.activities.email.MIMEMultipart') @@ -211,7 +289,7 @@ async def test_send_email(mimemultipart, mimetext, email): email.email_builder.send_notification = MagicMock() - email.server.sendmail = MagicMock( + email.try_send_email = MagicMock( side_effect=[ None, Exception("test") @@ -271,11 +349,11 @@ async def test_send_email(mimemultipart, mimetext, email): ] ) - email.server.sendmail.assert_has_calls( + email.try_send_email.assert_has_calls( [ - call('test@test.com', 'test@test.com, test2@test.com', - side_effect_1.as_string.return_value), - call('test@test.com', 'test3@test.com, test4@test.com', - side_effect_2.as_string.return_value) + call(side_effect_1, 'test@test.com, test2@test.com'), + call(side_effect_2, 'test3@test.com, test4@test.com') ] ) + + assert email.try_send_email.call_count == 2