diff --git a/env_secrets/local_daniel.env b/env_secrets/local_daniel.env index 24e09d02..2a0d57f3 100644 Binary files a/env_secrets/local_daniel.env and b/env_secrets/local_daniel.env differ diff --git a/scripts/send_sendgrid_email.py b/scripts/send_sendgrid_email.py new file mode 100644 index 00000000..7a157eab --- /dev/null +++ b/scripts/send_sendgrid_email.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +import os +import sys + +import django + +sys.path.append("../server") + +os.environ.setdefault("IT_APP_ENVIRONMENT", "local") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.base") +django.setup() + +from vbv_lernwelt.notify.email.email_services import ( + EmailTemplate, + send_email, + create_template_data_from_course_session_attendance_course, +) +from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse + + +def main(): + print("start") + + +if __name__ == "__main__": + main() + + csac = CourseSessionAttendanceCourse.objects.get(pk=1) + print(csac) + print(csac.trainer) + print(csac.due_date) + + result = send_email( + to_emails="daniel.egger+sendgrid@gmail.com", + template=EmailTemplate.ATTENDANCE_COURSE_REMINDER, + template_data=create_template_data_from_course_session_attendance_course(csac), + template_language="de", + fail_silently=False, + ) + print(result) diff --git a/server/requirements/requirements-dev.in b/server/requirements/requirements-dev.in index e48515ea..bba2128e 100644 --- a/server/requirements/requirements-dev.in +++ b/server/requirements/requirements-dev.in @@ -30,6 +30,7 @@ django-debug-toolbar # https://github.com/jazzband/django-debug-toolbar django-extensions # https://github.com/django-extensions/django-extensions django-coverage-plugin # https://github.com/nedbat/django_coverage_plugin pytest-django # https://github.com/pytest-dev/pytest-django +freezegun # https://github.com/spulec/freezegun # django-watchfiles custom PR https://github.com/q0w/django-watchfiles/archive/issue-1.zip diff --git a/server/requirements/requirements-dev.txt b/server/requirements/requirements-dev.txt index 04cf3105..71898040 100644 --- a/server/requirements/requirements-dev.txt +++ b/server/requirements/requirements-dev.txt @@ -218,6 +218,8 @@ flake8==6.1.0 # flake8-isort flake8-isort==6.0.0 # via -r requirements-dev.in +freezegun==1.2.2 + # via -r requirements-dev.in gitdb==4.0.10 # via gitdb2 gitdb2==4.0.2 @@ -409,6 +411,7 @@ python-dateutil==2.8.2 # -r requirements.in # botocore # faker + # freezegun python-dotenv==1.0.0 # via # environs diff --git a/server/vbv_lernwelt/assignment/services.py b/server/vbv_lernwelt/assignment/services.py index 66487d6d..95e84229 100644 --- a/server/vbv_lernwelt/assignment/services.py +++ b/server/vbv_lernwelt/assignment/services.py @@ -16,7 +16,8 @@ from vbv_lernwelt.core.models import User from vbv_lernwelt.core.utils import find_first from vbv_lernwelt.course.models import CourseCompletionStatus, CourseSession from vbv_lernwelt.course.services import mark_course_completion -from vbv_lernwelt.notify.service import EmailTemplate, NotificationService +from vbv_lernwelt.notify.email.email_services import EmailTemplate +from vbv_lernwelt.notify.services import NotificationService def update_assignment_completion( diff --git a/server/vbv_lernwelt/feedback/models.py b/server/vbv_lernwelt/feedback/models.py index b12cb286..59b7dc9b 100644 --- a/server/vbv_lernwelt/feedback/models.py +++ b/server/vbv_lernwelt/feedback/models.py @@ -6,7 +6,8 @@ from django.utils.translation import gettext_lazy as _ from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import CourseSessionUser -from vbv_lernwelt.notify.service import EmailTemplate, NotificationService +from vbv_lernwelt.notify.email.email_services import EmailTemplate +from vbv_lernwelt.notify.services import NotificationService class FeedbackIntegerField(models.IntegerField): diff --git a/server/vbv_lernwelt/notify/email/email_services.py b/server/vbv_lernwelt/notify/email/email_services.py new file mode 100644 index 00000000..91be1747 --- /dev/null +++ b/server/vbv_lernwelt/notify/email/email_services.py @@ -0,0 +1,97 @@ +from enum import Enum + +import structlog +from django.conf import settings +from django.utils import timezone + +from sendgrid import Mail, SendGridAPIClient + +from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse + +logger = structlog.get_logger(__name__) + +DATETIME_FORMAT_SWISS_STR = "%d.%m.%Y %H:%M" + + +def format_swiss_datetime(dt: timezone.datetime) -> str: + return dt.astimezone(timezone.get_current_timezone()).strftime( + DATETIME_FORMAT_SWISS_STR + ) + + +class EmailTemplate(Enum): + """Enum for the different Sendgrid email templates.""" + + # VBV - Erinnerung Präsenzkurse + ATTENDANCE_COURSE_REMINDER = { + "de": "d-9af079f98f524d85ac6e4166de3480da", + "it": "d-ab78ddca8a7a46b8afe50aaba3efee81", + "fr": "d-f88d9912e5484e55a879571463e4a166", + } + + # VBV - Geleitete Fallarbeit abgegeben + CASEWORK_SUBMITTED = {"de": "d-599f0b35ddcd4fac99314cdf8f5446a2"} + + # VBV - Geleitete Fallarbeit bewertet + CASEWORK_EVALUATED = {"de": "d-8c57fa13116b47be8eec95dfaf2aa030"} + + # VBV - Neues Feedback für Circle + NEW_FEEDBACK = {"de": "d-40fb94d5149949e7b8e7ddfcf0fcfdde"} + + +def send_email( + to_emails: str | list[str], + template: EmailTemplate, + template_data: dict, + template_language: str = "de", + fail_silently: bool = True, +) -> bool: + log = logger.bind( + recipient_emails=to_emails, + template=template.name, + template_data=template_data, + template_language=template_language, + ) + try: + send_sendgrid_email( + to_emails=to_emails, + template=template, + template_data=template_data, + template_language=template_language, + ) + log.info("Email sent successfully") + return True + except Exception as e: + log.error( + "Failed to send Email", exception=str(e), exc_info=True, stack_info=True + ) + if not fail_silently: + raise e + return False + + +def send_sendgrid_email( + to_emails: str | list[str], + template: EmailTemplate, + template_data: dict, + template_language: str = "de", +) -> None: + message = Mail( + from_email="noreply@my.vbv-afa.ch", + to_emails=to_emails, + ) + message.template_id = template.value.get(template_language, template.value["de"]) + message.dynamic_template_data = template_data + SendGridAPIClient(settings.SENDGRID_API_KEY).send(message) + + +def create_template_data_from_course_session_attendance_course( + attendance_course: CourseSessionAttendanceCourse, +): + return { + "attendance_course": attendance_course.learning_content.title, + "location": attendance_course.location, + "trainer": attendance_course.trainer, + "start": format_swiss_datetime(attendance_course.due_date.start), + "end": format_swiss_datetime(attendance_course.due_date.end), + } diff --git a/server/vbv_lernwelt/notify/management/commands/send_attendance_course_reminders.py b/server/vbv_lernwelt/notify/management/commands/send_attendance_course_reminders.py index 7690d744..2cd8ae45 100644 --- a/server/vbv_lernwelt/notify/management/commands/send_attendance_course_reminders.py +++ b/server/vbv_lernwelt/notify/management/commands/send_attendance_course_reminders.py @@ -8,19 +8,18 @@ from django.utils.translation import gettext_lazy as _ from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import CourseSessionUser from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse -from vbv_lernwelt.notify.service import EmailTemplate, NotificationService +from vbv_lernwelt.notify.email.email_services import ( + create_template_data_from_course_session_attendance_course, + EmailTemplate, +) +from vbv_lernwelt.notify.services import NotificationService logger = structlog.get_logger(__name__) PRESENCE_COURSE_REMINDER_LEAD_TIME = timedelta(weeks=2) -DATETIME_FORMAT_STR = "%H:%M %d.%m.%Y" -def format_datetime(dt: timezone.datetime) -> str: - return dt.astimezone(timezone.get_current_timezone()).strftime(DATETIME_FORMAT_STR) - - -def send_notification( +def send_attendance_course_reminder_notification( recipient: User, attendance_course: CourseSessionAttendanceCourse ): NotificationService.send_information_notification( @@ -28,24 +27,20 @@ def send_notification( verb=_("Erinnerung: Bald findet ein Präsenzkurs statt"), target_url=attendance_course.learning_content.get_frontend_url(), email_template=EmailTemplate.ATTENDANCE_COURSE_REMINDER, - template_data={ - "attendance_course": attendance_course.learning_content.title, - "location": attendance_course.location, - "trainer": attendance_course.trainer, - "start": format_datetime(attendance_course.due_date.start), - "end": format_datetime(attendance_course.due_date.end), - }, + template_data=create_template_data_from_course_session_attendance_course( + attendance_course=attendance_course + ), ) -def check_attendance_course(): +def attendance_course_reminder_notification_job(): """Checks if an attendance course is coming up and sends a reminder to the participants""" start = timezone.now() end = timezone.now() + PRESENCE_COURSE_REMINDER_LEAD_TIME logger.info( "Querying for attendance courses in specified time range", - start_time=start.strftime(DATETIME_FORMAT_STR), - end_time=end.strftime(DATETIME_FORMAT_STR), + start_time=start, + end_time=end, ) attendance_courses = CourseSessionAttendanceCourse.objects.filter( due_date__start__lte=end, @@ -55,11 +50,11 @@ def check_attendance_course(): cs_id = attendance_course.course_session.id csu = CourseSessionUser.objects.filter(course_session_id=cs_id) for user in csu: - send_notification(user.user, attendance_course) + send_attendance_course_reminder_notification(user.user, attendance_course) if not attendance_courses: logger.info("No attendance courses found") @click.command() def command(): - check_attendance_course() + attendance_course_reminder_notification_job() diff --git a/server/vbv_lernwelt/notify/service.py b/server/vbv_lernwelt/notify/services.py similarity index 74% rename from server/vbv_lernwelt/notify/service.py rename to server/vbv_lernwelt/notify/services.py index 5176928a..4a493dff 100644 --- a/server/vbv_lernwelt/notify/service.py +++ b/server/vbv_lernwelt/notify/services.py @@ -1,60 +1,15 @@ -from enum import Enum from typing import Optional import structlog from notifications.signals import notify -from sendgrid import Mail, SendGridAPIClient -from storages.utils import setting from vbv_lernwelt.core.models import User +from vbv_lernwelt.notify.email.email_services import EmailTemplate, send_email from vbv_lernwelt.notify.models import Notification, NotificationType logger = structlog.get_logger(__name__) -class EmailTemplate(Enum): - """Enum for the different Sendgrid email templates.""" - - # VBV - Erinnerung Präsenzkurse - ATTENDANCE_COURSE_REMINDER = { - "de": "d-9af079f98f524d85ac6e4166de3480da", - "it": "d-ab78ddca8a7a46b8afe50aaba3efee81", - "fr": "d-f88d9912e5484e55a879571463e4a166", - } - - # VBV - Geleitete Fallarbeit abgegeben - CASEWORK_SUBMITTED = {"de": "d-599f0b35ddcd4fac99314cdf8f5446a2"} - - # VBV - Geleitete Fallarbeit bewertet - CASEWORK_EVALUATED = {"de": "d-8c57fa13116b47be8eec95dfaf2aa030"} - - # VBV - Neues Feedback für Circle - NEW_FEEDBACK = {"de": "d-40fb94d5149949e7b8e7ddfcf0fcfdde"} - - -class EmailService: - """Email service class implemented using the Sendgrid API""" - - _sendgrid_client = SendGridAPIClient(setting("SENDGRID_API_KEY")) - - @classmethod - def send_email( - cls, - recipient: User, - template: EmailTemplate, - template_data: dict, - ) -> None: - message = Mail( - from_email="noreply@my.vbv-afa.ch", - to_emails=recipient.email, - ) - message.template_id = template.value.get( - recipient.language, template.value["de"] - ) - message.dynamic_template_data = template_data - cls._sendgrid_client.send(message) - - class NotificationService: @classmethod def send_user_interaction_notification( @@ -153,7 +108,7 @@ class NotificationService: template_name=email_template.name, template_data=template_data, ): - log.warn("A duplicate notification was omitted from being sent") + log.info("A duplicate notification was omitted from being sent") return emailed = False @@ -200,26 +155,14 @@ class NotificationService: def _send_email( recipient: User, template: EmailTemplate, - template_data: dict | None, + template_data: dict, ) -> bool: - log = logger.bind( - recipient=recipient.username, - template=template.name, + return send_email( + to_emails=recipient.email, + template=template, template_data=template_data, + template_language=recipient.language, ) - try: - EmailService.send_email( - recipient=recipient, - template=template, - template_data=template_data, - ) - log.info("Email sent successfully") - return True - except Exception as e: - log.error( - "Failed to send Email", exception=str(e), exc_info=True, stack_info=True - ) - return False @staticmethod def _is_duplicate_notification( diff --git a/server/vbv_lernwelt/notify/tests/test_send_attendance_course_reminders.py b/server/vbv_lernwelt/notify/tests/test_send_attendance_course_reminders.py index e618d6fb..04bc4cc2 100644 --- a/server/vbv_lernwelt/notify/tests/test_send_attendance_course_reminders.py +++ b/server/vbv_lernwelt/notify/tests/test_send_attendance_course_reminders.py @@ -6,6 +6,7 @@ from django.contrib.auth.models import User from django.test import TestCase from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from freezegun import freeze_time from vbv_lernwelt.core.constants import TEST_COURSE_SESSION_BERN_ID from vbv_lernwelt.core.create_default_users import create_default_users @@ -13,10 +14,10 @@ from vbv_lernwelt.course.creators.test_course import create_test_course from vbv_lernwelt.course.models import CourseSession, CourseSessionUser from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse from vbv_lernwelt.learnpath.models import LearningContentAttendanceCourse +from vbv_lernwelt.notify.email.email_services import EmailTemplate from vbv_lernwelt.notify.management.commands.send_attendance_course_reminders import ( - check_attendance_course, + attendance_course_reminder_notification_job, ) -from vbv_lernwelt.notify.service import EmailTemplate @dataclass @@ -36,7 +37,7 @@ def on_send_notification(**kwargs) -> None: @patch( - "vbv_lernwelt.notify.service.NotificationService.send_information_notification", + "vbv_lernwelt.notify.services.NotificationService.send_information_notification", on_send_notification, ) class TestAttendanceCourseReminders(TestCase): @@ -75,6 +76,9 @@ class TestAttendanceCourseReminders(TestCase): location="Handelsschule BV Bern, Zimmer 122", trainer="Thomas Berger", ) + + @freeze_time("2023-08-25 13:02:01") + def test_happy_day(self): in_two_weeks = datetime.now() + timedelta(weeks=2, days=1) self.csac_future.due_date.start = timezone.make_aware( in_two_weeks.replace(hour=5, minute=20, second=0, microsecond=0) @@ -84,8 +88,7 @@ class TestAttendanceCourseReminders(TestCase): ) self.csac_future.due_date.save() - def test_happy_day(self): - check_attendance_course() + attendance_course_reminder_notification_job() self.assertEquals(3, len(sent_notifications)) recipients = CourseSessionUser.objects.filter( course_session_id=self.csac.course_session.id @@ -94,6 +97,7 @@ class TestAttendanceCourseReminders(TestCase): set(map(lambda n: n.recipient, sent_notifications)), set(map(lambda csu: csu.user, recipients)), ) + for notification in sent_notifications: self.assertEquals( _("Erinnerung: Bald findet ein Präsenzkurs statt"), @@ -116,10 +120,10 @@ class TestAttendanceCourseReminders(TestCase): notification.template_data["trainer"], ) self.assertEquals( - self.csac.due_date.start.strftime("%H:%M %d.%m.%Y"), + self.csac.due_date.start.strftime("%d.%m.%Y %H:%M"), notification.template_data["start"], ) self.assertEquals( - self.csac.due_date.end.strftime("%H:%M %d.%m.%Y"), + self.csac.due_date.end.strftime("%d.%m.%Y %H:%M"), notification.template_data["end"], ) diff --git a/server/vbv_lernwelt/notify/tests/test_service.py b/server/vbv_lernwelt/notify/tests/test_service.py index 423245a1..15dbac49 100644 --- a/server/vbv_lernwelt/notify/tests/test_service.py +++ b/server/vbv_lernwelt/notify/tests/test_service.py @@ -4,8 +4,9 @@ from django.test import TestCase from vbv_lernwelt.core.models import User from vbv_lernwelt.core.tests.factories import UserFactory +from vbv_lernwelt.notify.email.email_services import EmailTemplate from vbv_lernwelt.notify.models import Notification, NotificationType -from vbv_lernwelt.notify.service import EmailTemplate, NotificationService +from vbv_lernwelt.notify.services import NotificationService class TestNotificationService(TestCase):