diff --git a/backend/.coveragerc b/backend/.coveragerc index aa134029f3..4d67696f1d 100644 --- a/backend/.coveragerc +++ b/backend/.coveragerc @@ -14,6 +14,7 @@ omit = hct_mis_api/libs/*, hct_mis_api/settings/*, hct_mis_api/settings.py, + hct_mis_api/conftest.py, hct_mis_api/config/settings.py hct_mis_api/apps/core/management/commands/* @@ -34,6 +35,7 @@ exclude_lines = #if 0: if __name__ == .__main__.: if TYPE_CHECKING + ^\s*(import\s.+|from\s+.+import\s+.+) # skip imports fail_under = 15 diff --git a/backend/hct_mis_api/apps/account/filters.py b/backend/hct_mis_api/apps/account/filters.py index f5968cb8b4..858c990e5e 100644 --- a/backend/hct_mis_api/apps/account/filters.py +++ b/backend/hct_mis_api/apps/account/filters.py @@ -7,7 +7,7 @@ from django_filters import BooleanFilter, CharFilter, FilterSet, MultipleChoiceFilter from hct_mis_api.apps.account.models import USER_STATUS_CHOICES, Partner, Role -from hct_mis_api.apps.core.utils import CustomOrderingFilter +from hct_mis_api.apps.core.utils import CustomOrderingFilter, decode_id_string if TYPE_CHECKING: from uuid import UUID @@ -19,6 +19,7 @@ class UsersFilter(FilterSet): business_area = CharFilter(required=True, method="business_area_filter") + program = CharFilter(required=False, method="program_filter") search = CharFilter(method="search_filter") status = MultipleChoiceFilter(field_name="status", choices=USER_STATUS_CHOICES) partner = MultipleChoiceFilter(choices=Partner.get_partners_as_choices(), method="partners_filter") @@ -65,10 +66,11 @@ def search_filter(self, qs: "QuerySet", name: str, value: str) -> "QuerySet[User return qs.filter(q_obj) def business_area_filter(self, qs: "QuerySet", name: str, value: str) -> "QuerySet[User]": - return qs.filter( - Q(user_roles__business_area__slug=value) - | Q(partner__business_area_partner_through__business_area__slug=value) - ) + return qs.filter(Q(user_roles__business_area__slug=value) | Q(partner__business_areas__slug=value)) + + def program_filter(self, qs: "QuerySet", name: str, value: str) -> "QuerySet[User]": + program_id = decode_id_string(value) + return qs.filter(partner__programs__id=program_id) def partners_filter(self, qs: "QuerySet", name: str, values: List["UUID"]) -> "QuerySet[User]": q_obj = Q() @@ -83,5 +85,8 @@ def roles_filter(self, qs: "QuerySet", name: str, values: List) -> "QuerySet[Use q_obj |= Q( user_roles__role__id=value, user_roles__business_area__slug=business_area_slug, + ) | Q( + partner__business_area_partner_through__roles__id=value, + partner__business_areas__slug=business_area_slug, ) return qs.filter(q_obj) diff --git a/backend/hct_mis_api/apps/account/models.py b/backend/hct_mis_api/apps/account/models.py index a3167aff62..b5d3f83639 100644 --- a/backend/hct_mis_api/apps/account/models.py +++ b/backend/hct_mis_api/apps/account/models.py @@ -1,6 +1,6 @@ import logging from functools import lru_cache -from typing import Any, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union from uuid import UUID from django import forms @@ -31,6 +31,7 @@ from hct_mis_api.apps.core.mixins import LimitBusinessAreaModelMixin from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough from hct_mis_api.apps.geo.models import Area +from hct_mis_api.apps.utils.mailjet import MailjetClient from hct_mis_api.apps.utils.models import TimeStampedUUIDModel from hct_mis_api.apps.utils.validators import ( DoubleSpaceValidator, @@ -264,6 +265,33 @@ def can_add_business_area_to_partner(self) -> bool: for role in self.cached_user_roles() ) + def email_user( # type: ignore + self, + subject: str, + html_body: Optional[str] = None, + text_body: Optional[str] = None, + mailjet_template_id: Optional[int] = None, + body_variables: Optional[Dict[str, Any]] = None, + from_email: Optional[str] = None, + from_email_display: Optional[str] = None, + ccs: Optional[list[str]] = None, + ) -> None: + """ + Send email to this user via Mailjet. + """ + email = MailjetClient( + recipients=[self.email], + subject=subject, + html_body=html_body, + text_body=text_body, + mailjet_template_id=mailjet_template_id, + variables=body_variables, + ccs=ccs, + from_email=from_email, + from_email_display=from_email_display, + ) + email.send_email() + class Meta: permissions = ( ("can_load_from_ad", "Can load users from ActiveDirectory"), diff --git a/backend/hct_mis_api/apps/account/schema.py b/backend/hct_mis_api/apps/account/schema.py index 1d813f008d..d7fbf50b64 100644 --- a/backend/hct_mis_api/apps/account/schema.py +++ b/backend/hct_mis_api/apps/account/schema.py @@ -29,7 +29,7 @@ hopeOneOfPermissionClass, ) from hct_mis_api.apps.core.extended_connection import ExtendedConnection -from hct_mis_api.apps.core.models import BusinessArea +from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough from hct_mis_api.apps.core.schema import ChoiceObject from hct_mis_api.apps.core.utils import decode_id_string, to_choice_object from hct_mis_api.apps.geo.models import Area @@ -66,6 +66,13 @@ class Meta: exclude = ("id",) +class PartnerRoleNode(DjangoObjectType): + + class Meta: + model = BusinessAreaPartnerThrough + exclude = ("id",) + + class RoleChoiceObject(graphene.ObjectType): name = graphene.String() value = graphene.String() @@ -96,10 +103,14 @@ class Meta: class UserNode(DjangoObjectType): business_areas = DjangoFilterConnectionField(UserBusinessAreaNode) + partner_roles = graphene.List(PartnerRoleNode) def resolve_business_areas(self, info: Any) -> "QuerySet[BusinessArea]": return info.context.user.business_areas + def resolve_partner_roles(self, info: Any) -> "QuerySet[Role]": + return self.partner.business_area_partner_through.all() + class Meta: model = get_user_model() exclude = ("password",) diff --git a/backend/hct_mis_api/apps/account/tests/snapshots/snap_test_user_filters.py b/backend/hct_mis_api/apps/account/tests/snapshots/snap_test_user_filters.py index 570e6d6b73..a9b51f3c31 100644 --- a/backend/hct_mis_api/apps/account/tests/snapshots/snap_test_user_filters.py +++ b/backend/hct_mis_api/apps/account/tests/snapshots/snap_test_user_filters.py @@ -14,25 +14,127 @@ { 'node': { 'partner': { - 'name': 'UNICEF' + 'name': 'Default Empty Partner' }, - 'username': 'unicef_user' + 'partnerRoles': [ + ], + 'userRoles': [ + { + 'businessArea': { + 'name': 'Afghanistan' + }, + 'role': { + 'name': 'Test Role', + 'permissions': None + } + } + ], + 'username': 'user_with_test_role' } }, + { + 'node': { + 'partner': { + 'name': 'Partner With Test Role' + }, + 'partnerRoles': [ + { + 'businessArea': { + 'name': 'Afghanistan' + }, + 'roles': [ + { + 'name': 'Test Role', + 'permissions': None + }, + { + 'name': 'User Management View Role', + 'permissions': [ + 'USER_MANAGEMENT_VIEW_LIST' + ] + } + ] + } + ], + 'userRoles': [ + ], + 'username': 'user_with_partner_with_test_role' + } + } + ] + } + } +} + +snapshots['TestUserFilter::test_users_by_program 1'] = { + 'data': { + 'allUsers': { + 'edges': [ + { + 'node': { + 'partner': { + 'name': 'Partner With Test Role' + }, + 'username': 'user_with_partner_with_test_role' + } + } + ] + } + } +} + +snapshots['TestUserFilter::test_users_by_roles 1'] = { + 'data': { + 'allUsers': { + 'edges': [ { 'node': { 'partner': { 'name': 'Default Empty Partner' }, - 'username': 'user_in_ba' + 'partnerRoles': [ + ], + 'userRoles': [ + { + 'businessArea': { + 'name': 'Afghanistan' + }, + 'role': { + 'name': 'Test Role', + 'permissions': None + } + } + ], + 'username': 'user_with_test_role' } }, { 'node': { 'partner': { - 'name': 'Test Partner' + 'name': 'Partner With Test Role' }, - 'username': 'user_with_partner_in_ba' + 'partnerRoles': [ + { + 'businessArea': { + 'name': 'Afghanistan' + }, + 'roles': [ + { + 'name': 'Test Role', + 'permissions': None + }, + { + 'name': 'User Management View Role', + 'permissions': [ + 'USER_MANAGEMENT_VIEW_LIST' + ] + } + ] + } + ], + 'userRoles': [ + ], + 'username': 'user_with_partner_with_test_role' } } ] diff --git a/backend/hct_mis_api/apps/account/tests/test_user_filters.py b/backend/hct_mis_api/apps/account/tests/test_user_filters.py index f1f779b2ca..e8a2e7b22c 100644 --- a/backend/hct_mis_api/apps/account/tests/test_user_filters.py +++ b/backend/hct_mis_api/apps/account/tests/test_user_filters.py @@ -1,7 +1,14 @@ -from hct_mis_api.apps.account.fixtures import PartnerFactory, UserFactory +from hct_mis_api.apps.account.fixtures import ( + PartnerFactory, + RoleFactory, + UserFactory, + UserRoleFactory, +) +from hct_mis_api.apps.account.models import User from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.core.base_test_case import APITestCase from hct_mis_api.apps.core.fixtures import create_afghanistan +from hct_mis_api.apps.core.utils import encode_id_base64_required from hct_mis_api.apps.program.fixtures import ProgramFactory @@ -21,46 +28,151 @@ class TestUserFilter(APITestCase): partner { name } + userRoles { + businessArea { + name + } + role { + name + permissions + } + } + partnerRoles { + businessArea { + name + } + roles { + name + permissions + } + } } } } } -""" + """ + + ALL_USERS_QUERY_FILTER_BY_PROGRAM = """ + query AllUsers( + $program: String + $businessArea: String! + $orderBy: String + ) { + allUsers( + program: $program + businessArea: $businessArea + orderBy: $orderBy + ) { + edges { + node { + username + partner { + name + } + } + } + } + } + """ + + ALL_USERS_QUERY_FILTER_BY_ROLES = """ + query AllUsers( + $roles: [String] + $businessArea: String! + $orderBy: String + ) { + allUsers( + roles: $roles + businessArea: $businessArea + orderBy: $orderBy + ) { + edges { + node { + username + partner { + name + } + userRoles { + businessArea { + name + } + role { + name + permissions + } + } + partnerRoles { + businessArea { + name + } + roles { + name + permissions + } + } + } + } + } + } + """ @classmethod def setUpTestData(cls) -> None: + User.objects.all().delete() business_area = create_afghanistan() - - # user with UNICEF partner partner_unicef = PartnerFactory(name="UNICEF") - program = ProgramFactory(name="Program") - cls.user_with_unicef_partner = UserFactory(partner=partner_unicef, username="unicef_user") - cls.create_user_role_with_permissions( - cls.user_with_unicef_partner, [Permissions.USER_MANAGEMENT_VIEW_LIST], business_area - ) + cls.program = ProgramFactory(name="Test Program") - # user with access to BA - user_in_ba = UserFactory(username="user_in_ba", partner=None) - cls.create_user_role_with_permissions(user_in_ba, [Permissions.GRIEVANCES_CREATE], business_area) + # user with UNICEF partner without role in BA + UserFactory(partner=partner_unicef, username="unicef_user_without_role") - # user with partner with role in BA - partner = PartnerFactory(name="Test Partner") - cls.create_partner_role_with_permissions( - partner=partner, - permissions=[Permissions.GRIEVANCES_CREATE], + # user without access to BA + partner_without_ba_role = PartnerFactory(name="Partner Without Access") + UserFactory(partner=partner_without_ba_role, username="user_without_BA_role") + + cls.role = RoleFactory(name="Test Role") + + # user with role in BA + user_with_test_role = UserFactory(username="user_with_test_role", partner=None) + UserRoleFactory(user=user_with_test_role, role=cls.role, business_area=business_area) + + # user with partner with role in BA and access to program + role_management = RoleFactory( + name="User Management View Role", permissions=[Permissions.USER_MANAGEMENT_VIEW_LIST.value] + ) + partner_with_test_role = PartnerFactory(name="Partner With Test Role") + cls.add_partner_role_in_business_area( + partner=partner_with_test_role, business_area=business_area, - name="Partner Role", - program=program, + roles=[cls.role, role_management], ) - UserFactory(partner=partner, username="user_with_partner_in_ba") - - # user without access to BA - partner_without_ba_access = PartnerFactory(name="Partner Without Access") - UserFactory(partner=partner_without_ba_access, username="user_without_BA_access") + cls.update_partner_access_to_program( + partner=partner_with_test_role, + program=cls.program, + ) + cls.user = UserFactory(username="user_with_partner_with_test_role", partner=partner_with_test_role) def test_users_by_business_area(self) -> None: self.snapshot_graphql_request( request_string=self.ALL_USERS_QUERY, variables={"businessArea": "afghanistan", "orderBy": "partner"}, - context={"user": self.user_with_unicef_partner}, + context={"user": self.user}, + ) + + def test_users_by_program(self) -> None: + self.snapshot_graphql_request( + request_string=self.ALL_USERS_QUERY_FILTER_BY_PROGRAM, + variables={ + "businessArea": "afghanistan", + "program": encode_id_base64_required(self.program.id, "Program"), + "orderBy": "partner", + }, + context={"user": self.user}, + ) + + def test_users_by_roles(self) -> None: + self.snapshot_graphql_request( + request_string=self.ALL_USERS_QUERY_FILTER_BY_ROLES, + variables={"businessArea": "afghanistan", "roles": [str(self.role.id)], "orderBy": "partner"}, + context={"user": self.user}, ) diff --git a/backend/hct_mis_api/apps/core/utils.py b/backend/hct_mis_api/apps/core/utils.py index 947c7149ac..b8a9179d7a 100644 --- a/backend/hct_mis_api/apps/core/utils.py +++ b/backend/hct_mis_api/apps/core/utils.py @@ -893,10 +893,9 @@ def send_email_notification_on_commit(service: Any, user: "User") -> None: context = service.get_email_context(user) transaction.on_commit( lambda: user.email_user( - context["title"], - render_to_string(service.text_template, context=context), - settings.EMAIL_HOST_USER, - html_message=render_to_string(service.html_template, context=context), + subject=context["title"], + html_body=render_to_string(service.html_template, context=context), + text_body=render_to_string(service.text_template, context=context), ) ) @@ -906,16 +905,13 @@ def send_email_notification( user: Optional["User"] = None, context_kwargs: Optional[Dict] = None, ) -> None: - if context_kwargs is None: - context_kwargs = {} if context_kwargs: context = service.get_email_context(**context_kwargs) else: context = service.get_email_context(user) if user else service.get_email_context() user = user or service.user user.email_user( - context["title"], - render_to_string(service.text_template, context=context), - settings.EMAIL_HOST_USER, - html_message=render_to_string(service.html_template, context=context), + subject=context["title"], + html_body=render_to_string(service.html_template, context=context), + text_body=render_to_string(service.text_template, context=context), ) diff --git a/backend/hct_mis_api/apps/grievance/notifications.py b/backend/hct_mis_api/apps/grievance/notifications.py index 445e4f9e97..40a7be52ac 100644 --- a/backend/hct_mis_api/apps/grievance/notifications.py +++ b/backend/hct_mis_api/apps/grievance/notifications.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple from django.conf import settings -from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.utils import timezone @@ -12,6 +11,7 @@ from hct_mis_api.apps.account.models import User, UserRole from hct_mis_api.apps.core.utils import encode_id_base64 from hct_mis_api.apps.grievance.models import GrievanceTicket +from hct_mis_api.apps.utils.mailjet import MailjetClient logger = logging.getLogger(__name__) @@ -56,29 +56,26 @@ def _prepare_user_recipients(self) -> Any: func: Callable = GrievanceNotification.ACTION_PREPARE_USER_RECIPIENTS_DICT[self.action] return func(self) - def _prepare_emails(self) -> List[EmailMultiAlternatives]: + def _prepare_emails(self) -> List[MailjetClient]: return [self._prepare_email(user) for user in self.user_recipients] - def _prepare_email(self, user_recipient: "User") -> EmailMultiAlternatives: + def _prepare_email(self, user_recipient: "User") -> MailjetClient: prepare_bodies_method = GrievanceNotification.ACTION_PREPARE_BODIES_DICT[self.action] text_body, html_body, subject = prepare_bodies_method(self, user_recipient) - email = EmailMultiAlternatives( + email = MailjetClient( subject=subject, - from_email=settings.EMAIL_HOST_USER, - to=[user_recipient.email], - body=text_body, + recipients=[user_recipient.email], + html_body=html_body, + text_body=text_body, ) - email.attach_alternative(html_body, "text/html") return email def send_email_notification(self) -> None: - if self.enable_email_notification: - if not config.SEND_GRIEVANCES_NOTIFICATION: - return + if config.SEND_GRIEVANCES_NOTIFICATION and self.enable_email_notification: try: for email in self.emails: - email.send() - except Exception as e: + email.send_email() + except Exception as e: # pragma: no cover logger.exception(e) def _prepare_universal_category_created_bodies(self, user_recipient: "User") -> Tuple[str, str, str]: diff --git a/backend/hct_mis_api/apps/household/services/individuals_iban_xlsx_update.py b/backend/hct_mis_api/apps/household/services/individuals_iban_xlsx_update.py index 56be3f65f0..a5e9a4393e 100644 --- a/backend/hct_mis_api/apps/household/services/individuals_iban_xlsx_update.py +++ b/backend/hct_mis_api/apps/household/services/individuals_iban_xlsx_update.py @@ -1,8 +1,6 @@ import logging from typing import Any, Dict, Tuple -from django.conf import settings -from django.core.mail import EmailMultiAlternatives from django.db import transaction from django.db.models import QuerySet from django.template.loader import render_to_string @@ -16,6 +14,7 @@ Individual, XlsxUpdateFile, ) +from hct_mis_api.apps.utils.mailjet import MailjetClient logger = logging.getLogger(__name__) @@ -148,8 +147,8 @@ def send_failure_email(self) -> None: if self.business_area.enable_email_notification: email = self._prepare_email(context=self._get_email_context(message=str(self.validation_errors))) try: - email.send() - except Exception as e: + email.send_email() + except Exception as e: # pragma: no cover logger.exception(e) def send_success_email(self) -> None: @@ -158,8 +157,8 @@ def send_success_email(self) -> None: context=self._get_email_context(message="All of the Individuals IBAN number we're updated successfully") ) try: - email.send() - except Exception as e: + email.send_email() + except Exception as e: # pragma: no cover logger.exception(e) @classmethod @@ -174,12 +173,12 @@ def send_error_email(cls, error_message: str, xlsx_update_file_id: str, uploaded } email = cls._prepare_email(context=context) try: - email.send() - except Exception as e: + email.send_email() + except Exception as e: # pragma: no cover logger.exception(e) @staticmethod - def _prepare_email(context: Dict) -> EmailMultiAlternatives: + def _prepare_email(context: Dict) -> MailjetClient: text_body = render_to_string( "admin/household/individual/individuals_iban_xlsx_update_email.txt", context=context ) @@ -187,12 +186,11 @@ def _prepare_email(context: Dict) -> EmailMultiAlternatives: "admin/household/individual/individuals_iban_xlsx_update_email.txt", context=context ) - email = EmailMultiAlternatives( + email = MailjetClient( subject=f"Individual IBANs xlsx [{context['upload_file_id']}] update result", - from_email=settings.EMAIL_HOST_USER, - to=[context["email"]], - body=text_body, + recipients=[context["email"]], + html_body=html_body, + text_body=text_body, ) - email.attach_alternative(html_body, "text/html") return email diff --git a/backend/hct_mis_api/apps/household/tests/test_individual_iban_xlsx_update.py b/backend/hct_mis_api/apps/household/tests/test_individual_iban_xlsx_update.py index 2648cf54e7..c1838c0742 100644 --- a/backend/hct_mis_api/apps/household/tests/test_individual_iban_xlsx_update.py +++ b/backend/hct_mis_api/apps/household/tests/test_individual_iban_xlsx_update.py @@ -1,3 +1,4 @@ +import json from io import BytesIO from pathlib import Path from typing import Any @@ -5,7 +6,10 @@ from django.conf import settings from django.core.files import File -from django.test import TestCase +from django.template.loader import render_to_string +from django.test import TestCase, override_settings + +from constance.test import override_config from hct_mis_api.apps.account.fixtures import UserFactory from hct_mis_api.apps.core.fixtures import create_afghanistan @@ -128,66 +132,82 @@ def setUpTestData(cls) -> None: for individual in cls.individuals: individual.save() - @mock.patch( - "hct_mis_api.apps.household.services.individuals_iban_xlsx_update.IndividualsIBANXlsxUpdate._prepare_email" - ) - def test_update_individuals_iban_from_xlsx_task_invalid_file_error(self, prepare_email_mock: Any) -> None: + @mock.patch("hct_mis_api.apps.utils.mailjet.requests.post") + @override_settings(EMAIL_SUBJECT_PREFIX="test") + @override_config(ENABLE_MAILJET=True) + def test_update_individuals_iban_from_xlsx_task_invalid_file_error(self, mocked_requests_post: Any) -> None: update_individuals_iban_from_xlsx_task.run( xlsx_update_file_id=self.xlsx_invalid_file.id, uploaded_by_id=self.user.id, ) - prepare_email_mock.assert_called_once_with( - context={ - "first_name": self.user.first_name, - "last_name": self.user.last_name, - "email": self.user.email, - "message": "There was an unexpected error during Individuals IBAN update: 'Worksheet Individuals does not exist.'", - "upload_file_id": str(self.xlsx_invalid_file.id), - } + + context = { + "first_name": self.user.first_name, + "last_name": self.user.last_name, + "email": self.user.email, + "message": "There was an unexpected error during Individuals IBAN update: 'Worksheet Individuals does not exist.'", + "upload_file_id": str(self.xlsx_invalid_file.id), + } + + expected_data = self._get_expected_email_body(context) + + mocked_requests_post.assert_called_once_with( + "https://api.mailjet.com/v3.1/send", + auth=(settings.MAILJET_API_KEY, settings.MAILJET_SECRET_KEY), + data=expected_data, ) - @mock.patch( - "hct_mis_api.apps.household.services.individuals_iban_xlsx_update.IndividualsIBANXlsxUpdate._prepare_email" - ) + @mock.patch("hct_mis_api.apps.utils.mailjet.requests.post") + @override_settings(EMAIL_SUBJECT_PREFIX="test") + @override_config(ENABLE_MAILJET=True) def test_update_individuals_iban_from_xlsx_task_invalid_file_bad_columns_fail( - self, prepare_email_mock: Any + self, mocked_requests_post: Any ) -> None: update_individuals_iban_from_xlsx_task.run( xlsx_update_file_id=self.xlsx_invalid_file_bad_columns.id, uploaded_by_id=self.user.id, ) - prepare_email_mock.assert_called_once_with( - context={ - "first_name": self.user.first_name, - "last_name": self.user.last_name, - "email": self.user.email, - "message": "['No UNICEF_ID column in provided file', 'No IBAN column in provided file', 'No BANK_NAME column in provided file']", - "upload_file_id": str(self.xlsx_invalid_file_bad_columns.id), - } + context = { + "first_name": self.user.first_name, + "last_name": self.user.last_name, + "email": self.user.email, + "message": "['No UNICEF_ID column in provided file', 'No IBAN column in provided file', 'No BANK_NAME column in provided file']", + "upload_file_id": str(self.xlsx_invalid_file_bad_columns.id), + } + expected_data = self._get_expected_email_body(context) + + mocked_requests_post.assert_called_once_with( + "https://api.mailjet.com/v3.1/send", + auth=(settings.MAILJET_API_KEY, settings.MAILJET_SECRET_KEY), + data=expected_data, ) - @mock.patch( - "hct_mis_api.apps.household.services.individuals_iban_xlsx_update.IndividualsIBANXlsxUpdate._prepare_email" - ) - def test_update_individuals_iban_from_xlsx_task_invalid_no_match_fail(self, prepare_email_mock: Any) -> None: + @mock.patch("hct_mis_api.apps.utils.mailjet.requests.post") + @override_settings(EMAIL_SUBJECT_PREFIX="test") + @override_config(ENABLE_MAILJET=True) + def test_update_individuals_iban_from_xlsx_task_invalid_no_match_fail(self, mocked_requests_post: Any) -> None: update_individuals_iban_from_xlsx_task.run( xlsx_update_file_id=self.xlsx_invalid_file_no_match.id, uploaded_by_id=self.user.id, ) - prepare_email_mock.assert_called_once_with( - context={ - "first_name": self.user.first_name, - "last_name": self.user.last_name, - "email": self.user.email, - "message": "['No matching Individuals for rows: [2, 3]']", - "upload_file_id": str(self.xlsx_invalid_file_no_match.id), - } + context = { + "first_name": self.user.first_name, + "last_name": self.user.last_name, + "email": self.user.email, + "message": "['No matching Individuals for rows: [2, 3]']", + "upload_file_id": str(self.xlsx_invalid_file_no_match.id), + } + expected_data = self._get_expected_email_body(context) + mocked_requests_post.assert_called_once_with( + "https://api.mailjet.com/v3.1/send", + auth=(settings.MAILJET_API_KEY, settings.MAILJET_SECRET_KEY), + data=expected_data, ) - @mock.patch( - "hct_mis_api.apps.household.services.individuals_iban_xlsx_update.IndividualsIBANXlsxUpdate._prepare_email" - ) - def test_update_individuals_iban_from_xlsx_task_valid_match(self, prepare_email_mock: Any) -> None: + @mock.patch("hct_mis_api.apps.utils.mailjet.requests.post") + @override_settings(EMAIL_SUBJECT_PREFIX="test") + @override_config(ENABLE_MAILJET=True) + def test_update_individuals_iban_from_xlsx_task_valid_match(self, mocked_requests_post: Any) -> None: # creating BankAccountInfo for only one individual, second one should be populated on demand BankAccountInfoFactory(individual=self.individuals[0]) self.individuals[0].save() @@ -197,15 +217,20 @@ def test_update_individuals_iban_from_xlsx_task_valid_match(self, prepare_email_ uploaded_by_id=self.user.id, ) - prepare_email_mock.assert_called_once_with( - context={ - "first_name": self.user.first_name, - "last_name": self.user.last_name, - "email": self.user.email, - "message": "All of the Individuals IBAN number we're updated successfully", - "upload_file_id": str(self.xlsx_valid_file.id), - } + context = { + "first_name": self.user.first_name, + "last_name": self.user.last_name, + "email": self.user.email, + "message": "All of the Individuals IBAN number we're updated successfully", + "upload_file_id": str(self.xlsx_valid_file.id), + } + expected_data = self._get_expected_email_body(context) + mocked_requests_post.assert_called_once_with( + "https://api.mailjet.com/v3.1/send", + auth=(settings.MAILJET_API_KEY, settings.MAILJET_SECRET_KEY), + data=expected_data, ) + bank_account_info_0 = self.individuals[0].bank_account_info.first() bank_account_info_1 = self.individuals[1].bank_account_info.first() self.assertEqual(bank_account_info_0.bank_account_number, "1111111111") @@ -213,20 +238,50 @@ def test_update_individuals_iban_from_xlsx_task_valid_match(self, prepare_email_ self.assertEqual(bank_account_info_1.bank_account_number, "2222222222") self.assertEqual(bank_account_info_1.bank_name, "Bank") - @mock.patch( - "hct_mis_api.apps.household.services.individuals_iban_xlsx_update.IndividualsIBANXlsxUpdate._prepare_email" - ) - def test_update_individuals_iban_from_xlsx_task_invalid_empty_cell(self, prepare_email_mock: Any) -> None: + @mock.patch("hct_mis_api.apps.utils.mailjet.requests.post") + @override_settings(EMAIL_SUBJECT_PREFIX="test") + @override_config(ENABLE_MAILJET=True) + def test_update_individuals_iban_from_xlsx_task_invalid_empty_cell(self, mocked_requests_post: Any) -> None: update_individuals_iban_from_xlsx_task.run( xlsx_update_file_id=self.xlsx_invalid_file_empty_cell.id, uploaded_by_id=self.user.id, ) - prepare_email_mock.assert_called_once_with( - context={ - "first_name": self.user.first_name, - "last_name": self.user.last_name, - "email": self.user.email, - "message": "There was an unexpected error during Individuals IBAN update: BankAccountInfo data is missing for Individual IND-88-0000.0002 in Row 3, One of IBAN/BANK_NAME value was not provided. Please validate also other rows for missing data.", - "upload_file_id": str(self.xlsx_invalid_file_empty_cell.id), + context = { + "first_name": self.user.first_name, + "last_name": self.user.last_name, + "email": self.user.email, + "message": "There was an unexpected error during Individuals IBAN update: BankAccountInfo data is missing for Individual IND-88-0000.0002 in Row 3, One of IBAN/BANK_NAME value was not provided. Please validate also other rows for missing data.", + "upload_file_id": str(self.xlsx_invalid_file_empty_cell.id), + } + expected_data = self._get_expected_email_body(context) + mocked_requests_post.assert_called_once_with( + "https://api.mailjet.com/v3.1/send", + auth=(settings.MAILJET_API_KEY, settings.MAILJET_SECRET_KEY), + data=expected_data, + ) + + def _get_expected_email_body(self, context: dict) -> str: + return json.dumps( + { + "Messages": [ + { + "From": {"Email": settings.EMAIL_HOST_USER, "Name": settings.DEFAULT_FROM_EMAIL}, + "Subject": f"[test] Individual IBANs xlsx [{context['upload_file_id']}] update result", + "To": [ + { + "Email": "test@example.com", + }, + ], + "Cc": [], + "HTMLPart": render_to_string( + "admin/household/individual/individuals_iban_xlsx_update_email.txt", + context=context, + ), + "TextPart": render_to_string( + "admin/household/individual/individuals_iban_xlsx_update_email.txt", + context=context, + ), + } + ] } ) diff --git a/backend/hct_mis_api/apps/payment/notifications.py b/backend/hct_mis_api/apps/payment/notifications.py index 009ad72b66..234e5c3e68 100644 --- a/backend/hct_mis_api/apps/payment/notifications.py +++ b/backend/hct_mis_api/apps/payment/notifications.py @@ -112,13 +112,12 @@ def _prepare_email(self, user_recipient: User) -> MailjetClient: return email def send_email_notification(self) -> None: - if not config.SEND_PAYMENT_PLANS_NOTIFICATION or not self.enable_email_notification: - return - try: - for email in self.emails: - email.send_email() - except Exception as e: - logger.exception(e) + if config.SEND_PAYMENT_PLANS_NOTIFICATION and self.enable_email_notification: + try: + for email in self.emails: + email.send_email() + except Exception as e: # pragma: no cover + logger.exception(e) def _prepare_body_variables(self, user_recipient: User) -> Dict[str, Any]: protocol = "https" if settings.SOCIAL_AUTH_REDIRECT_IS_HTTPS else "http" diff --git a/backend/hct_mis_api/apps/payment/tests/test_finish_verification_plan.py b/backend/hct_mis_api/apps/payment/tests/test_finish_verification_plan.py index a51ce913aa..5ad41f8963 100644 --- a/backend/hct_mis_api/apps/payment/tests/test_finish_verification_plan.py +++ b/backend/hct_mis_api/apps/payment/tests/test_finish_verification_plan.py @@ -1,7 +1,12 @@ +from typing import Any +from unittest import mock + from django.conf import settings -from django.test import TestCase +from django.test import TestCase, override_settings + +from constance.test import override_config -from hct_mis_api.apps.account.fixtures import UserFactory +from hct_mis_api.apps.account.fixtures import RoleFactory, UserFactory, UserRoleFactory from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.geo.models import Area from hct_mis_api.apps.grievance.models import GrievanceTicket @@ -33,6 +38,8 @@ def setUpTestData(cls) -> None: business_area = create_afghanistan() payment_record_amount = 10 user = UserFactory() + role = RoleFactory(name="Releaser") + UserRoleFactory(user=user, role=role, business_area=business_area) afghanistan_areas_qs = Area.objects.filter(area_type__area_level=2, area_type__country__iso_code3="AFG") @@ -92,7 +99,10 @@ def setUpTestData(cls) -> None: EntitlementCardFactory(household=household) cls.verification = cash_plan.get_payment_verification_plans.first() - def test_create_tickets_with_admin2_same_as_in_household(self) -> None: + @mock.patch("hct_mis_api.apps.utils.mailjet.requests.post") + @override_settings(EMAIL_SUBJECT_PREFIX="test") + @override_config(SEND_GRIEVANCES_NOTIFICATION=True, ENABLE_MAILJET=True) + def test_create_tickets_with_admin2_same_as_in_household(self, mocked_requests_post: Any) -> None: VerificationPlanStatusChangeServices(self.verification).finish() ticket = GrievanceTicket.objects.filter(category=GrievanceTicket.CATEGORY_PAYMENT_VERIFICATION).first() @@ -102,3 +112,5 @@ def test_create_tickets_with_admin2_same_as_in_household(self) -> None: self.assertIsNotNone(ticket.admin2_id) self.assertIsNotNone(household.admin2_id) self.assertEqual(ticket.admin2_id, household.admin2_id) + + self.assertEqual(mocked_requests_post.call_count, 10) diff --git a/backend/hct_mis_api/apps/payment/tests/test_payment_notification.py b/backend/hct_mis_api/apps/payment/tests/test_payment_notification.py index 6a07039b1a..3758a6311e 100644 --- a/backend/hct_mis_api/apps/payment/tests/test_payment_notification.py +++ b/backend/hct_mis_api/apps/payment/tests/test_payment_notification.py @@ -292,9 +292,10 @@ def test_send_email_notification_subject_prod_env(self, mock_send: Any) -> None: self.assertEqual(mailjet_client.subject, "Payment pending for Approval") @mock.patch("hct_mis_api.apps.utils.mailjet.requests.post") - @override_config(SEND_PAYMENT_PLANS_NOTIFICATION=True) - @override_config(ENABLE_MAILJET=True) - @override_settings(CATCH_ALL_EMAIL="catchallemail@email.com") + @override_config( + SEND_PAYMENT_PLANS_NOTIFICATION=True, ENABLE_MAILJET=True, MAILJET_TEMPLATE_PAYMENT_PLAN_NOTIFICATION=1 + ) + @override_settings(CATCH_ALL_EMAIL=["catchallemail@email.com", "catchallemail2@email.com"]) def test_send_email_notification_catch_all_email(self, mock_post: Any) -> None: payment_notification = PaymentNotification( self.payment_plan, @@ -306,7 +307,7 @@ def test_send_email_notification_catch_all_email(self, mock_post: Any) -> None: for mailjet_client in payment_notification.emails: self.assertEqual( mailjet_client.recipients, - ["catchallemail@email.com"], + ["catchallemail@email.com", "catchallemail2@email.com"], ) self.assertEqual( mock_post.call_count, @@ -314,8 +315,9 @@ def test_send_email_notification_catch_all_email(self, mock_post: Any) -> None: ) @mock.patch("hct_mis_api.apps.utils.mailjet.requests.post") - @override_config(SEND_PAYMENT_PLANS_NOTIFICATION=True) - @override_config(ENABLE_MAILJET=True) + @override_config( + SEND_PAYMENT_PLANS_NOTIFICATION=True, ENABLE_MAILJET=True, MAILJET_TEMPLATE_PAYMENT_PLAN_NOTIFICATION=1 + ) def test_send_email_notification_without_catch_all_email(self, mock_post: Any) -> None: payment_notification = PaymentNotification( self.payment_plan, diff --git a/backend/hct_mis_api/apps/reporting/services/generate_dashboard_report_service.py b/backend/hct_mis_api/apps/reporting/services/generate_dashboard_report_service.py index 96f5967060..66f262c5c9 100644 --- a/backend/hct_mis_api/apps/reporting/services/generate_dashboard_report_service.py +++ b/backend/hct_mis_api/apps/reporting/services/generate_dashboard_report_service.py @@ -956,12 +956,12 @@ def generate_report(self) -> None: save=False, ) self.report.status = DashboardReport.COMPLETED - except Exception as e: + except Exception as e: # pragma: no cover logger.exception(e) self.report.status = DashboardReport.FAILED self.report.save() - if self.report.file: + if self.report.file and self.business_area.enable_email_notification: self._send_email() def _send_email(self) -> None: @@ -977,8 +977,11 @@ def _send_email(self) -> None: html_body = render_to_string("dashboard_report.html", context=context) subject = "HOPE report generated" - if self.business_area.enable_email_notification: - self.report.created_by.email_user(subject, text_body, settings.EMAIL_HOST_USER, html_message=html_body) + self.report.created_by.email_user( + subject=subject, + html_body=html_body, + text_body=text_body, + ) @staticmethod def _adjust_column_width_from_col(ws: "Worksheet", min_col: int, max_col: int, min_row: int) -> None: diff --git a/backend/hct_mis_api/apps/reporting/services/generate_report_service.py b/backend/hct_mis_api/apps/reporting/services/generate_report_service.py index 52d28712cb..93af700169 100644 --- a/backend/hct_mis_api/apps/reporting/services/generate_report_service.py +++ b/backend/hct_mis_api/apps/reporting/services/generate_report_service.py @@ -8,7 +8,6 @@ from django.contrib.postgres.aggregates.general import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.core.files import File -from django.core.mail import EmailMultiAlternatives from django.db import models from django.db.models import ( Case, @@ -54,6 +53,7 @@ PaymentVerificationPlan, ) from hct_mis_api.apps.reporting.models import Report +from hct_mis_api.apps.utils.mailjet import MailjetClient if TYPE_CHECKING: from hct_mis_api.apps.account.models import User @@ -887,14 +887,13 @@ def _send_email(self) -> None: } text_body = render_to_string("report.txt", context=context) html_body = render_to_string("report.html", context=context) - msg = EmailMultiAlternatives( + email = MailjetClient( subject="HOPE report generated", - from_email=settings.EMAIL_HOST_USER, - to=[self.report.created_by.email], - body=text_body, + recipients=[self.report.created_by.email], + html_body=html_body, + text_body=text_body, ) - msg.attach_alternative(html_body, "text/html") - msg.send() + email.send_email() def _add_missing_headers(self, ws: Worksheet, column_to_start: int, column_to_finish: int, label: str) -> None: for x in range(column_to_start, column_to_finish + 1): diff --git a/backend/hct_mis_api/apps/reporting/tests/test_generate_dashboard_report_service.py b/backend/hct_mis_api/apps/reporting/tests/test_generate_dashboard_report_service.py index cb70d1dc20..a8653ad39e 100644 --- a/backend/hct_mis_api/apps/reporting/tests/test_generate_dashboard_report_service.py +++ b/backend/hct_mis_api/apps/reporting/tests/test_generate_dashboard_report_service.py @@ -1,7 +1,15 @@ -from django.test import TestCase +import json +from typing import Any +from unittest.mock import patch +from django.conf import settings +from django.template.loader import render_to_string +from django.test import TestCase, override_settings + +from constance.test import override_config from parameterized import parameterized +from hct_mis_api.apps.account.fixtures import UserFactory from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.reporting.fixtures import DashboardReportFactory from hct_mis_api.apps.reporting.models import DashboardReport @@ -23,7 +31,9 @@ class TestGenerateDashboardReportService(TestCase): DashboardReport.PAYMENT_VERIFICATION, ] ) - def test_generate_report_successfully(self, report_type: str) -> None: + @patch("hct_mis_api.apps.utils.mailjet.requests.post") + @override_config(ENABLE_MAILJET=True) + def test_generate_report_successfully(self, report_type: str, mocked_requests_post: Any) -> None: create_afghanistan() report = DashboardReportFactory(status=DashboardReport.IN_PROGRESS, report_type=[report_type]) service = GenerateDashboardReportService(report=report) @@ -31,3 +41,72 @@ def test_generate_report_successfully(self, report_type: str) -> None: service.generate_report() self.assertEqual(report.status, DashboardReport.COMPLETED) + + mocked_requests_post.assert_called_once() + + @patch("hct_mis_api.apps.utils.mailjet.requests.post") + @override_config(ENABLE_MAILJET=True) + def test_generate_report_successfully_ba_notification_disabled(self, mocked_requests_post: Any) -> None: + afg = create_afghanistan() + afg.enable_email_notification = False + afg.save() + report = DashboardReportFactory( + status=DashboardReport.IN_PROGRESS, report_type=[DashboardReport.TOTAL_TRANSFERRED_BY_COUNTRY] + ) + service = GenerateDashboardReportService(report=report) + + service.generate_report() + + self.assertEqual(report.status, DashboardReport.COMPLETED) + + mocked_requests_post.assert_not_called() + + @patch("hct_mis_api.apps.utils.mailjet.requests.post") + @override_settings(EMAIL_SUBJECT_PREFIX="test") + @override_config(ENABLE_MAILJET=True) + def test_email_body_for_generate_report(self, mocked_requests_post: Any) -> None: + create_afghanistan() + user = UserFactory(email="testemail@email.com") + report_type = DashboardReport.TOTAL_TRANSFERRED_BY_COUNTRY + report = DashboardReportFactory(status=DashboardReport.IN_PROGRESS, report_type=[report_type], created_by=user) + service = GenerateDashboardReportService(report=report) + + service.generate_report() + + self.assertEqual(report.status, DashboardReport.COMPLETED) + + mocked_requests_post.assert_called_once() + + context = { + "report_type": dict(DashboardReport.REPORT_TYPES)[report_type], + "created_at": report.created_at.strftime("%Y-%m-%d"), + "report_url": f"http://example.com/api/dashboard-report/{report.id}", + "title": "Report", + } + text_body = render_to_string("dashboard_report.txt", context=context) + html_body = render_to_string("dashboard_report.html", context=context) + + expected_data = json.dumps( + { + "Messages": [ + { + "From": {"Email": settings.EMAIL_HOST_USER, "Name": settings.DEFAULT_FROM_EMAIL}, + "Subject": "[test] HOPE report generated", + "To": [ + { + "Email": "testemail@email.com", + }, + ], + "Cc": [], + "HTMLPart": html_body, + "TextPart": text_body, + } + ] + } + ) + + mocked_requests_post.assert_called_with( + "https://api.mailjet.com/v3.1/send", + auth=(settings.MAILJET_API_KEY, settings.MAILJET_SECRET_KEY), + data=expected_data, + ) diff --git a/backend/hct_mis_api/apps/sanction_list/tasks/check_against_sanction_list.py b/backend/hct_mis_api/apps/sanction_list/tasks/check_against_sanction_list.py index 98b4d96464..bc876910c1 100644 --- a/backend/hct_mis_api/apps/sanction_list/tasks/check_against_sanction_list.py +++ b/backend/hct_mis_api/apps/sanction_list/tasks/check_against_sanction_list.py @@ -1,10 +1,10 @@ +import base64 +import io from datetime import date, datetime from itertools import permutations -from tempfile import NamedTemporaryFile from typing import TYPE_CHECKING from django.conf import settings -from django.core.mail import EmailMultiAlternatives from django.db.models import Q from django.template.loader import render_to_string from django.utils import timezone @@ -17,6 +17,7 @@ SanctionListIndividual, UploadedXLSXFile, ) +from hct_mis_api.apps.utils.mailjet import MailjetClient if TYPE_CHECKING: from uuid import UUID @@ -127,20 +128,23 @@ def execute(self, uploaded_file_id: "UUID", original_file_name: str) -> None: for i in range(1, len(header_row_names) + 1): attachment_ws.column_dimensions[get_column_letter(i)].width = 30 - with NamedTemporaryFile() as tmp: - attachment = bytes(tmp.read()) + buffer = io.BytesIO() + attachment_wb.save(buffer) + buffer.seek(0) - msg = EmailMultiAlternatives( + attachment_content = buffer.getvalue() + base64_encoded_content = base64.b64encode(attachment_content).decode("utf-8") + + email = MailjetClient( subject=subject, - from_email=settings.EMAIL_HOST_USER, - to=[uploaded_file.associated_email], - cc=[settings.SANCTION_LIST_CC_MAIL], - body=text_body, + recipients=[uploaded_file.associated_email], + html_body=html_body, + text_body=text_body, + ccs=[settings.SANCTION_LIST_CC_MAIL], ) - msg.attach( - f"{subject}.xlsx", - attachment, - "application/vnd.ms-excel", + email.attach_file( + attachment=base64_encoded_content, + filename=f"{subject}.xlsx", + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) - msg.attach_alternative(html_body, "text/html") - msg.send() + email.send_email() diff --git a/backend/hct_mis_api/apps/sanction_list/tests/test_check_against_sanction_list_task.py b/backend/hct_mis_api/apps/sanction_list/tests/test_check_against_sanction_list_task.py new file mode 100644 index 0000000000..5b92315aa9 --- /dev/null +++ b/backend/hct_mis_api/apps/sanction_list/tests/test_check_against_sanction_list_task.py @@ -0,0 +1,111 @@ +import base64 +import io +import json +from typing import Any +from unittest.mock import patch + +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.template.loader import render_to_string +from django.test import TestCase, override_settings +from django.utils import timezone + +from constance.test import override_config +from freezegun import freeze_time +from openpyxl import Workbook +from openpyxl.utils import get_column_letter + +from hct_mis_api.apps.sanction_list.models import UploadedXLSXFile +from hct_mis_api.apps.sanction_list.tasks.check_against_sanction_list import ( + CheckAgainstSanctionListTask, +) + + +class TestSanctionList(TestCase): + def setUp(self) -> None: + self.uploaded_file = UploadedXLSXFile.objects.create( + file=SimpleUploadedFile("test.xlsx", b"test"), + associated_email="test_email@email.com", + ) + + @patch("hct_mis_api.apps.utils.mailjet.requests.post") + @patch("hct_mis_api.apps.sanction_list.tasks.check_against_sanction_list.load_workbook") + @override_settings(EMAIL_SUBJECT_PREFIX="test") + @override_config(ENABLE_MAILJET=True) + @freeze_time("2024-01-10 01:01:01") + def test_sanction_list_email(self, mocked_load_workbook: Any, mocked_requests_post: Any) -> None: + class MockLoadWorkbook: + class MockSheet: + def iter_rows(self, min_row: int) -> list: + return [] + + def __getitem__(self, key: int) -> list: + return [] + + worksheets = [MockSheet()] + + mocked_load_workbook.returned_value = MockLoadWorkbook() + + CheckAgainstSanctionListTask().execute(self.uploaded_file.id, "test.xlsx") + + attachment_wb = Workbook() + attachment_ws = attachment_wb.active + attachment_ws.title = "Sanction List Check Results" + + header_row_names = ( + "FIRST NAME", + "SECOND NAME", + "THIRD NAME", + "FOURTH NAME", + "DATE OF BIRTH", + "ORIGINAL FILE ROW NUMBER", + ) + attachment_ws.append(header_row_names) + for i in range(1, len(header_row_names) + 1): + attachment_ws.column_dimensions[get_column_letter(i)].width = 30 + buffer = io.BytesIO() + attachment_wb.save(buffer) + buffer.seek(0) + + attachment_content = buffer.getvalue() + base64_encoded_content = base64.b64encode(attachment_content).decode("utf-8") + + original_file_name = "test.xlsx" + context = { + "results": {}, + "results_count": 0, + "file_name": original_file_name, + "today_date": timezone.now(), + "title": "Sanction List Check", + } + subject = ( + f"Sanction List Check - file: {original_file_name}, " + f"date: {timezone.now().strftime('%Y-%m-%d %I:%M %p')}" + ) + expected_data = json.dumps( + { + "Messages": [ + { + "From": {"Email": settings.EMAIL_HOST_USER, "Name": settings.DEFAULT_FROM_EMAIL}, + "Subject": f"[test] {subject}", + "To": [{"Email": "test_email@email.com"}], + "Cc": [{"Email": settings.SANCTION_LIST_CC_MAIL}], + "HTMLPart": render_to_string("sanction_list/check_results.html", context=context), + "TextPart": render_to_string("sanction_list/check_results.txt", context=context), + "Attachments": [ + { + "ContentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Filename": f"{subject}.xlsx", + "Base64Content": base64_encoded_content, + } + ], + } + ] + } + ) + + mocked_requests_post.assert_called_once_with( + "https://api.mailjet.com/v3.1/send", + auth=(settings.MAILJET_API_KEY, settings.MAILJET_SECRET_KEY), + data=expected_data, + ) diff --git a/backend/hct_mis_api/apps/utils/mailjet.py b/backend/hct_mis_api/apps/utils/mailjet.py index 775f55d5a0..8733848631 100644 --- a/backend/hct_mis_api/apps/utils/mailjet.py +++ b/backend/hct_mis_api/apps/utils/mailjet.py @@ -14,28 +14,49 @@ class MailjetClient: def __init__( self, - mailjet_template_id: int, subject: str, recipients: list[str], + mailjet_template_id: Optional[int] = None, + html_body: Optional[str] = None, + text_body: Optional[str] = None, ccs: Optional[list[str]] = None, variables: Optional[Dict[str, Any]] = None, + from_email: Optional[str] = None, + from_email_display: Optional[str] = None, ) -> None: self.mailjet_template_id = mailjet_template_id + self.html_body = html_body + self.text_body = text_body subject_prefix = settings.EMAIL_SUBJECT_PREFIX self.subject = f"[{subject_prefix}] {subject}" if subject_prefix else subject - self.recipients = recipients + self.recipients = settings.CATCH_ALL_EMAIL if settings.CATCH_ALL_EMAIL else recipients self.ccs = ccs or [] self.variables = variables + self.from_email = from_email or settings.EMAIL_HOST_USER + self.from_email_display = from_email_display or settings.DEFAULT_FROM_EMAIL + self.attachments = [] + + def _validate_email_data(self) -> None: + if self.mailjet_template_id and (self.html_body or self.text_body): + raise ValueError("You cannot use both template and custom email body") + if not self.mailjet_template_id and not (self.html_body or self.text_body): + raise ValueError("You need to provide either template or custom email body") + if self.mailjet_template_id and not self.variables: + raise ValueError("You need to provide body variables for template email") def send_email(self) -> bool: if not config.ENABLE_MAILJET: return False - if settings.CATCH_ALL_EMAIL: - self.recipients = [settings.CATCH_ALL_EMAIL] + + self._validate_email_data() + + email_body = self._get_email_body() + attachments = {"Attachments": self.attachments} if self.attachments else {} + data = { "Messages": [ { - "From": {"Email": settings.EMAIL_HOST_USER, "Name": settings.DEFAULT_FROM_EMAIL}, + "From": {"Email": self.from_email, "Name": self.from_email_display}, "Subject": self.subject, "To": [ { @@ -49,9 +70,8 @@ def send_email(self) -> bool: } for cc in self.ccs ], - "TemplateID": self.mailjet_template_id, - "TemplateLanguage": True, - "Variables": self.variables, + **email_body, + **attachments, } ] } @@ -62,3 +82,32 @@ def send_email(self) -> bool: data=data_json, ) return res.status_code == 200 + + def _get_email_body(self) -> Dict[str, Any]: + """ + Construct the dictionary with the data responsible for email body, + built for passed content (html and/or text) or mailjet template (with variables). + """ + if self.mailjet_template_id: + return { + "TemplateID": self.mailjet_template_id, + "TemplateLanguage": True, + "Variables": self.variables, + } + else: # Body content provided through html_body and/or text_body + content = {} + if self.html_body: + content["HTMLPart"] = self.html_body + if self.text_body: + content["TextPart"] = self.text_body + return content + + def attach_file(self, attachment: str, filename: str, mimetype: str) -> None: + new_attachment = [ + { + "ContentType": mimetype, + "Filename": filename, + "Base64Content": attachment, + } + ] + self.attachments.extend(new_attachment) diff --git a/backend/hct_mis_api/config/fragments/mailjet.py b/backend/hct_mis_api/config/fragments/mailjet.py index 0dea73a2a8..612e87bc6c 100644 --- a/backend/hct_mis_api/config/fragments/mailjet.py +++ b/backend/hct_mis_api/config/fragments/mailjet.py @@ -2,4 +2,4 @@ MAILJET_API_KEY = env("MAILJET_API_KEY") MAILJET_SECRET_KEY = env("MAILJET_SECRET_KEY") -CATCH_ALL_EMAIL = env("CATCH_ALL_EMAIL", default="") +CATCH_ALL_EMAIL = env.list("CATCH_ALL_EMAIL", default=[]) diff --git a/backend/hct_mis_api/config/settings.py b/backend/hct_mis_api/config/settings.py index 7d3bc7309b..9c1e729621 100644 --- a/backend/hct_mis_api/config/settings.py +++ b/backend/hct_mis_api/config/settings.py @@ -88,7 +88,6 @@ MANIFEST_FILE = "web/.vite/manifest.json" DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL") -CATCH_ALL_EMAIL = env("CATCH_ALL_EMAIL", default="") EMAIL_BACKEND = env("EMAIL_BACKEND") if not DEBUG else "django.core.mail.backends.console.EmailBackend" EMAIL_HOST = env("EMAIL_HOST") diff --git a/backend/hct_mis_api/conftest.py b/backend/hct_mis_api/conftest.py index e05cc5f678..761e1a1d3c 100644 --- a/backend/hct_mis_api/conftest.py +++ b/backend/hct_mis_api/conftest.py @@ -32,7 +32,7 @@ def pytest_configure(config: Config) -> None: settings.ELASTICSEARCH_INDEX_PREFIX = "test_" settings.EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" - settings.CATCH_ALL_EMAIL = "" + settings.CATCH_ALL_EMAIL = [] settings.EXCHANGE_RATE_CACHE_EXPIRY = 0 settings.USE_DUMMY_EXCHANGE_RATES = True diff --git a/backend/tests/apps/utils/__init__.py b/backend/tests/apps/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/apps/utils/test_mailjet.py b/backend/tests/apps/utils/test_mailjet.py new file mode 100644 index 0000000000..ec7af49ff9 --- /dev/null +++ b/backend/tests/apps/utils/test_mailjet.py @@ -0,0 +1,382 @@ +import base64 +import io +import json +from typing import Any +from unittest.mock import patch + +from django.conf import settings +from django.test import override_settings + +import pytest +from constance.test import override_config +from openpyxl import Workbook + +from hct_mis_api.apps.account.fixtures import UserFactory +from hct_mis_api.apps.utils.mailjet import MailjetClient + + +class TestMailjet: + @patch("hct_mis_api.apps.utils.mailjet.requests.post") + @override_settings(EMAIL_SUBJECT_PREFIX="test") + @override_config(ENABLE_MAILJET=True) + def test_mailjet_body_with_template(self, mocked_requests_post: Any) -> None: + mailjet = MailjetClient( + mailjet_template_id=1, + subject="Subject for email with Template", + recipients=["test@email.com", "test2@email.com"], + ccs=["testcc@email.com"], + variables={"key": "value"}, + ) + mailjet.send_email() + mocked_requests_post.assert_called_once() + expected_data = json.dumps( + { + "Messages": [ + { + "From": {"Email": settings.EMAIL_HOST_USER, "Name": settings.DEFAULT_FROM_EMAIL}, + "Subject": "[test] Subject for email with Template", + "To": [ + { + "Email": "test@email.com", + }, + { + "Email": "test2@email.com", + }, + ], + "Cc": [ + { + "Email": "testcc@email.com", + } + ], + "TemplateID": 1, + "TemplateLanguage": True, + "Variables": {"key": "value"}, + } + ] + } + ) + + mocked_requests_post.assert_called_with( + "https://api.mailjet.com/v3.1/send", + auth=(settings.MAILJET_API_KEY, settings.MAILJET_SECRET_KEY), + data=expected_data, + ) + + @patch("hct_mis_api.apps.utils.mailjet.requests.post") + @override_settings( + EMAIL_SUBJECT_PREFIX="test", CATCH_ALL_EMAIL=["catchallemail@email.com", "catchallemail2@email.com"] + ) + @override_config(ENABLE_MAILJET=True) + def test_mailjet_body_with_template_with_catch_all(self, mocked_requests_post: Any) -> None: + mailjet = MailjetClient( + mailjet_template_id=1, + subject="Subject for email with Template for Catch All", + recipients=["test@email.com", "test2@email.com"], + ccs=["testcc@email.com"], + variables={"key": "value"}, + ) + mailjet.send_email() + mocked_requests_post.assert_called_once() + expected_data = json.dumps( + { + "Messages": [ + { + "From": {"Email": settings.EMAIL_HOST_USER, "Name": settings.DEFAULT_FROM_EMAIL}, + "Subject": "[test] Subject for email with Template for Catch All", + "To": [ + { + "Email": "catchallemail@email.com", + }, + { + "Email": "catchallemail2@email.com", + }, + ], + "Cc": [ + { + "Email": "testcc@email.com", + } + ], + "TemplateID": 1, + "TemplateLanguage": True, + "Variables": {"key": "value"}, + } + ] + } + ) + + mocked_requests_post.assert_called_with( + "https://api.mailjet.com/v3.1/send", + auth=(settings.MAILJET_API_KEY, settings.MAILJET_SECRET_KEY), + data=expected_data, + ) + + @patch("hct_mis_api.apps.utils.mailjet.requests.post") + @override_settings(EMAIL_SUBJECT_PREFIX="test") + @override_config(ENABLE_MAILJET=True) + def test_mailjet_body_with_html_and_text_body(self, mocked_requests_post: Any) -> None: + mailjet = MailjetClient( + html_body="
- {el.businessArea.name} /{el.role.name} + {el.businessArea.name} / {el.role.name}
)); + + const mappedPartnerRoles = user?.partnerRoles?.map((el) => + el.roles.map((role) => ( ++ {el.businessArea.name} / {role.name} +
+ )), + ); + return ( <>- - - - | -- - Name - - - | -- - Status - - sorted descending - - - - | -- - Partner - - - | -- - Email - - - | -- - Last Login - - - | -
---|---|---|---|---|---|
- - | -|||||
- - | -|||||
- - | -|||||
- - | -|||||
- - | -|||||
- - | -|||||
- - | -|||||
- - | -|||||
- - | -|||||
- - | -- - | -
-
-
-
- ACTIVE
-
- |
- - UNICEF - | -- wojciech.nosal@tivix.com - | -- - | -
- | - |