From 527d0c24c68ecffd2aac68c9633436e2ffd07cfd Mon Sep 17 00:00:00 2001 From: Emil Johnsen <111747340+1Cezzo@users.noreply.github.com> Date: Thu, 26 Sep 2024 16:01:27 +0200 Subject: [PATCH] Endpoint for sending email (#883) * fix formatting * created tests for send_email endpoint * Fix code scanning alert no. 45: Information exposure through an exception Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fixed errors in send_email and in tests * lint * added tests for empty lists and for sending mail to multiple users --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- app/communication/enums.py | 6 + ...ernotificationsetting_notification_type.py | 37 +++ app/communication/tests/test_send_email.py | 216 ++++++++++++++++++ app/content/urls.py | 2 + app/content/views/__init__.py | 1 + app/content/views/send_email.py | 84 +++++++ 6 files changed, 346 insertions(+) create mode 100644 app/communication/migrations/0011_alter_usernotificationsetting_notification_type.py create mode 100644 app/communication/tests/test_send_email.py create mode 100644 app/content/views/send_email.py diff --git a/app/communication/enums.py b/app/communication/enums.py index d9c6fc606..f664be7fd 100644 --- a/app/communication/enums.py +++ b/app/communication/enums.py @@ -18,3 +18,9 @@ class UserNotificationSettingType(models.TextChoices): RESERVATION_NEW = "RESERVATION NEW", "Ny reservasjon" RESERVATION_APPROVED = "RESERVATION APPROVED", "Godkjent reservasjon" RESERVATION_CANCELLED = "RESERVATION CANCELLED", "Avslått reservasjon" + KONTRES = "KONTRES", "Kontres" + BLITZED = "BLITZED", "Blitzed" + + @classmethod + def get_kontres_and_blitzed(cls): + return [cls.KONTRES, cls.BLITZED] diff --git a/app/communication/migrations/0011_alter_usernotificationsetting_notification_type.py b/app/communication/migrations/0011_alter_usernotificationsetting_notification_type.py new file mode 100644 index 000000000..b368b134c --- /dev/null +++ b/app/communication/migrations/0011_alter_usernotificationsetting_notification_type.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.5 on 2024-09-23 18:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("communication", "0010_alter_usernotificationsetting_notification_type"), + ] + + operations = [ + migrations.AlterField( + model_name="usernotificationsetting", + name="notification_type", + field=models.CharField( + choices=[ + ("REGISTRATION", "Påmeldingsoppdateringer"), + ("UNREGISTRATION", "Avmeldingsoppdateringer"), + ("STRIKE", "Prikkoppdateringer"), + ("EVENT_SIGN_UP_START", "Arrangementer - påmeldingsstart"), + ("EVENT_SIGN_OFF_DEADLINE", "Arrangementer - avmeldingsfrist"), + ("EVENT_EVALUATION", "Arrangementer - evaluering"), + ("EVENT_INFO", "Arrangementer - info fra arrangør"), + ("FINE", "Grupper - bot"), + ("GROUP_MEMBERSHIP", "Grupper - medlemsskap"), + ("OTHER", "Andre"), + ("RESERVATION NEW", "Ny reservasjon"), + ("RESERVATION APPROVED", "Godkjent reservasjon"), + ("RESERVATION CANCELLED", "Avslått reservasjon"), + ("KONTRES", "Kontres"), + ("BLITZED", "Blitzed"), + ], + max_length=30, + ), + ), + ] diff --git a/app/communication/tests/test_send_email.py b/app/communication/tests/test_send_email.py new file mode 100644 index 000000000..eef51ca42 --- /dev/null +++ b/app/communication/tests/test_send_email.py @@ -0,0 +1,216 @@ +import os +from unittest.mock import patch + +from rest_framework import status + +import pytest + +from app.communication.enums import UserNotificationSettingType +from app.communication.notifier import Notify +from app.content.factories import UserFactory +from app.util.test_utils import get_api_client + +EMAIL_URL = "/send-email/" +EMAIL_API_KEY = os.environ.get("EMAIL_API_KEY") + + +def _get_email_url(): + return f"{EMAIL_URL}" + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_success(mock_send): + """ + Test that the send_email endpoint sends an email successfully. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_201_CREATED + mock_send.assert_called_once() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_field_missing(mock_send): + """ + Test that the send_email endpoint returns 400 when one of the fields is missing. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + mock_send.assert_not_called() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_wrong_api_key(mock_send): + """ + Test that the send_email endpoint returns 403 when the API key is invalid. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": "wrong_key"} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_send.assert_not_called() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_user_id_invalid(mock_send): + """ + Test that the send_email endpoint returns 404 when the user id is invalid. + """ + test_user = UserFactory() + + data = { + "user_id_list": [999], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_404_NOT_FOUND + mock_send.assert_not_called() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +@pytest.mark.parametrize( + "notification_type", UserNotificationSettingType.get_kontres_and_blitzed() +) +def test_email_success_with_kontres_and_blitzed(mock_send, notification_type): + """ + Tests that the send_email endpoint works with both KONTRES and BLITZED notification types. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "notification_type": notification_type, + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_201_CREATED + mock_send.assert_called_once() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_success_with_user_id_list(mock_send): + """ + Test that the send_email endpoint sends an email successfully to a list of users. + """ + test_user1 = UserFactory() + test_user2 = UserFactory() + test_user3 = UserFactory() + + data = { + "user_id_list": [ + test_user.user_id for test_user in [test_user1, test_user2, test_user3] + ], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user1) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_201_CREATED + mock_send.assert_called_once() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_user_id_list_is_empty(mock_send): + """ + Test that the send_email endpoint returns 400 when the user id list is empty. + """ + test_user = UserFactory() + + data = { + "user_id_list": [], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + mock_send.assert_not_called() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_user_paragraph_list_is_empty(mock_send): + """ + Test that the send_email endpoint returns 400 when the paragraph list is empty. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": [], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + mock_send.assert_not_called() diff --git a/app/content/urls.py b/app/content/urls.py index c5a482395..26911fd69 100644 --- a/app/content/urls.py +++ b/app/content/urls.py @@ -20,6 +20,7 @@ accept_form, delete, register_with_feide, + send_email, upload, ) @@ -53,6 +54,7 @@ re_path(r"", include(router.urls)), path("accept-form/", accept_form), path("upload/", upload), + path("send-email/", send_email), path("delete-file///", delete), path("feide/", register_with_feide), re_path(r"users/(?P[^/.]+)/events.ics", UserCalendarEvents()), diff --git a/app/content/views/__init__.py b/app/content/views/__init__.py index 3392d438c..5a9a5c3f4 100644 --- a/app/content/views/__init__.py +++ b/app/content/views/__init__.py @@ -16,3 +16,4 @@ from app.content.views.logentry import LogEntryViewSet from app.content.views.minute import MinuteViewSet from app.content.views.feide import register_with_feide +from app.content.views.send_email import send_email diff --git a/app/content/views/send_email.py b/app/content/views/send_email.py new file mode 100644 index 000000000..2619632d4 --- /dev/null +++ b/app/content/views/send_email.py @@ -0,0 +1,84 @@ +import os + +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from app.communication.enums import UserNotificationSettingType +from app.communication.notifier import Notify +from app.content.models import User + + +@api_view(["POST"]) +def send_email(request): + """ + Endpoint for sending a notification and email to a user. + + Body should contain: + - 'user_id_list': A list of user ids to send the email to. + - 'notification_type': KONTRES or BLITZED. + - 'title': The title of the notification. + - 'paragraphs': A list of paragraphs to include in the notification. + + The header should contain: + - 'api_key': A key for validating access. + + """ + try: + EMAIL_API_KEY = os.environ.get("EMAIL_API_KEY") + api_key = request.META.get("api_key") + if api_key != EMAIL_API_KEY: + return Response( + {"detail": "Feil API nøkkel"}, + status=status.HTTP_403_FORBIDDEN, + ) + + user_id_list = request.data.get("user_id_list") + paragraphs = request.data.get("paragraphs") + title = request.data.get("title") + notification_type = request.data.get("notification_type") + + if not isinstance(user_id_list, list) or not user_id_list: + return Response( + {"detail": "En ikke-tom liste med bruker id-er må inkluderes"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not isinstance(paragraphs, list) or not paragraphs: + return Response( + {"detail": "En ikke-tom liste med paragrafer må inkluderes"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not notification_type or not title: + return Response( + { + "detail": "Notifikasjonstype (KONTRES/BLITZED) og tittel må være satt" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + users = list(User.objects.filter(user_id__in=user_id_list)) + if not users or len(users) != len(user_id_list): + return Response( + {"detail": "En eller flere brukere ble ikke funnet"}, + status=status.HTTP_404_NOT_FOUND, + ) + + email = Notify( + users, + f"{title}", + UserNotificationSettingType(notification_type), + ) + + for paragraph in paragraphs: + email.add_paragraph(paragraph) + + email.send() + return Response({"detail": "Emailen ble sendt"}, status=status.HTTP_201_CREATED) + except Exception as e: + print(e) + return Response( + {"detail": "Det oppsto en feil under sending av email"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + )