diff --git a/README.md b/README.md index 38346e28..68c667d1 100644 --- a/README.md +++ b/README.md @@ -121,12 +121,12 @@ There are multiple ways on how to add new translations to Locize: ### Process one: Let Locize add missing keys automatically When running the app, it will automatically add the missing translation -keys to Locize. +keys to Locize. There you can translate them, and also add the German translation. ### Process two: Add keys manually -You can add the new keys manually to the German locale file in +You can add the new keys manually to the German locale file in ./client/locales/de/translation.json Then you can run the following command to add the keys to Locize: @@ -149,7 +149,6 @@ The command will add the keys and the German translation to Locize. Bonus: Use the "i18n ally" plugin in VSCode or IntelliJ to get extract untranslated texts directly from the code to the translation.json file. - ### "_many" plural form in French and Italian See https://github.com/i18next/i18next/issues/1691#issuecomment-968063348 @@ -296,7 +295,6 @@ npm run dev If you run `npm run dev`, the codegen command will be run automatically in watch mode. - For the `ObjectTypes` on the server, please use the postfix `ObjectType` for types, like `LearningContentAttendanceCourseObjectType`. diff --git a/compose/django/Dockerfile b/compose/django/Dockerfile index aa489641..85e4ba33 100644 --- a/compose/django/Dockerfile +++ b/compose/django/Dockerfile @@ -18,13 +18,28 @@ COPY ./server ${APP_HOME} # define an alias for the specfic python version used in this file. FROM python:${PYTHON_VERSION} as python +# Setup Supersonic (Cron scheduler for containers) +# https://github.com/aptible/supercronic +ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.26/supercronic-linux-amd64 \ + SUPERCRONIC=supercronic-linux-amd64 \ + SUPERCRONIC_SHA1SUM=7a79496cf8ad899b99a719355d4db27422396735 + +RUN apt-get update && apt-get install --no-install-recommends -y curl +RUN curl -fsSLO "$SUPERCRONIC_URL" \ + && echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \ + && chmod +x "$SUPERCRONIC" \ + && mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \ + && ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic + +COPY ./compose/django/send_attendance_course_reminders /app/send_attendance_course_reminders + # Python build stage FROM python as python-build-stage ARG BUILD_ENVIRONMENT=production # Install apt packages -RUN apt-get update && apt-get install --no-install-recommends -y \ +RUN apt-get install --no-install-recommends -y \ # dependencies for building Python packages build-essential \ # psycopg2 dependencies @@ -56,7 +71,7 @@ RUN addgroup --system django \ # Install required system dependencies -RUN apt-get update && apt-get install --no-install-recommends -y \ +RUN apt-get install --no-install-recommends -y \ # psycopg2 dependencies libpq-dev \ # Translations dependencies diff --git a/compose/django/docker_start.sh b/compose/django/docker_start.sh index d78b21d7..2f359bd2 100644 --- a/compose/django/docker_start.sh +++ b/compose/django/docker_start.sh @@ -15,4 +15,9 @@ else python /app/manage.py migrate fi +if [[ $IT_APP_ENVIRONMENT != dev* ]]; then + # Start periodic tasks + /usr/local/bin/supercronic -sentry-dsn "$IT_SENTRY_DSN" -split-logs /app/send_attendance_course_reminders & +fi + newrelic-admin run-program gunicorn config.asgi --bind 0.0.0.0:7555 --chdir=/app -k uvicorn.workers.UvicornWorker diff --git a/compose/django/send_attendance_course_reminders b/compose/django/send_attendance_course_reminders new file mode 100644 index 00000000..4d3af4d1 --- /dev/null +++ b/compose/django/send_attendance_course_reminders @@ -0,0 +1 @@ +@daily /usr/local/bin/python /app/manage.py send_attendance_course_reminders diff --git a/server/config/settings/base.py b/server/config/settings/base.py index 87ffd78f..1465ca93 100644 --- a/server/config/settings/base.py +++ b/server/config/settings/base.py @@ -636,7 +636,7 @@ EDONIQ_CERTIFICATE = env("IT_EDONIQ_CERTIFICATE", default="") # Notifications # django-notifications -DJANGO_NOTIFICATIONS_CONFIG = {"SOFT_DELETE": True} +DJANGO_NOTIFICATIONS_CONFIG = {"SOFT_DELETE": True, "USE_JSONFIELD": True} NOTIFICATIONS_NOTIFICATION_MODEL = "notify.Notification" # sendgrid (email notifications) SENDGRID_API_KEY = env("IT_SENDGRID_API_KEY", default="") diff --git a/server/vbv_lernwelt/assignment/services.py b/server/vbv_lernwelt/assignment/services.py index e31913d0..66487d6d 100644 --- a/server/vbv_lernwelt/assignment/services.py +++ b/server/vbv_lernwelt/assignment/services.py @@ -16,7 +16,7 @@ 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 NotificationService +from vbv_lernwelt.notify.service import EmailTemplate, NotificationService def update_assignment_completion( @@ -144,6 +144,7 @@ def update_assignment_completion( sender=ac.assignment_user, course=course_session.course.title, target_url=ac.get_assignment_evaluation_frontend_url(), + email_template=EmailTemplate.CASEWORK_SUBMITTED, ) elif completion_status == AssignmentCompletionStatus.EVALUATION_SUBMITTED: ac.evaluation_submitted_at = timezone.now() @@ -162,6 +163,7 @@ def update_assignment_completion( sender=evaluation_user, course=course_session.course.title, target_url=assignment_frontend_url, + email_template=EmailTemplate.CASEWORK_EVALUATED, ) ac.completion_status = completion_status.value diff --git a/server/vbv_lernwelt/course/creators/test_course.py b/server/vbv_lernwelt/course/creators/test_course.py index f79428da..264965bf 100644 --- a/server/vbv_lernwelt/course/creators/test_course.py +++ b/server/vbv_lernwelt/course/creators/test_course.py @@ -105,14 +105,14 @@ def create_test_course(include_uk=True, include_vv=True, with_sessions=False): location="Handelsschule KV Bern, Zimmer 123, Eigerstrasse 16, 3012 Bern", trainer="Roland Grossenbacher, roland.grossenbacher@helvetia.ch", ) - tuesday_in_two_weeks = ( - datetime.now() + relativedelta(weekday=TU(2)) + relativedelta(weeks=2) + tuesday_in_one_week = ( + datetime.now() + relativedelta(weekday=TU) + relativedelta(weeks=1) ) csac.due_date.start = timezone.make_aware( - tuesday_in_two_weeks.replace(hour=8, minute=30, second=0, microsecond=0) + tuesday_in_one_week.replace(hour=8, minute=30, second=0, microsecond=0) ) csac.due_date.end = timezone.make_aware( - tuesday_in_two_weeks.replace(hour=17, minute=0, second=0, microsecond=0) + tuesday_in_one_week.replace(hour=17, minute=0, second=0, microsecond=0) ) csac.due_date.save() diff --git a/server/vbv_lernwelt/feedback/models.py b/server/vbv_lernwelt/feedback/models.py index f9c0aaf1..b12cb286 100644 --- a/server/vbv_lernwelt/feedback/models.py +++ b/server/vbv_lernwelt/feedback/models.py @@ -6,7 +6,7 @@ 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 NotificationService +from vbv_lernwelt.notify.service import EmailTemplate, NotificationService class FeedbackIntegerField(models.IntegerField): @@ -55,6 +55,7 @@ class FeedbackResponse(models.Model): recipient=csu.user, verb=f"{_('New feedback for circle')} {self.circle.title}", target_url=f"/course/{self.course_session.course.slug}/cockpit/feedback/{self.circle_id}/", + email_template=EmailTemplate.NEW_FEEDBACK, ) super(FeedbackResponse, self).save(*args, **kwargs) diff --git a/server/vbv_lernwelt/importer/services.py b/server/vbv_lernwelt/importer/services.py index 3e9802c1..912b6145 100644 --- a/server/vbv_lernwelt/importer/services.py +++ b/server/vbv_lernwelt/importer/services.py @@ -25,6 +25,7 @@ from vbv_lernwelt.learnpath.models import ( LearningContentAttendanceCourse, LearningContentEdoniqTest, ) +from vbv_lernwelt.notify.models import NotificationType logger = structlog.get_logger(__name__) @@ -248,7 +249,11 @@ def create_or_update_user( if not user: # create user - user = User(sso_id=sso_id, email=email, username=email) + user = User( + sso_id=sso_id, + email=email, + username=email, + ) user.email = email user.sso_id = user.sso_id or sso_id @@ -699,8 +704,9 @@ def sync_students_from_t2l(data): def update_user_json_data(user: User, data: Dict[str, Any]): + # Set E-Mail notification settings for new users user.additional_json_data = user.additional_json_data | sanitize_json_data_input( - data + {**data, "email_notification_types": [str(NotificationType.INFORMATION)]} ) diff --git a/server/vbv_lernwelt/importer/tests/test_import_students.py b/server/vbv_lernwelt/importer/tests/test_import_students.py index d771d89d..46880b60 100644 --- a/server/vbv_lernwelt/importer/tests/test_import_students.py +++ b/server/vbv_lernwelt/importer/tests/test_import_students.py @@ -52,6 +52,7 @@ class CreateOrUpdateStudentTestCase(TestCase): "Lehrvertragsnummer": "1234", "Tel. Privat": "079 593 83 43", "Geburtsdatum": "01.01.2000", + "email_notification_types": ["INFORMATION"], } def test_create_student(self): 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 new file mode 100644 index 00000000..7690d744 --- /dev/null +++ b/server/vbv_lernwelt/notify/management/commands/send_attendance_course_reminders.py @@ -0,0 +1,65 @@ +from datetime import timedelta + +import djclick as click +import structlog +from django.utils import timezone +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 + +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( + recipient: User, attendance_course: CourseSessionAttendanceCourse +): + NotificationService.send_information_notification( + recipient=recipient, + 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), + }, + ) + + +def check_attendance_course(): + """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), + ) + attendance_courses = CourseSessionAttendanceCourse.objects.filter( + due_date__start__lte=end, + due_date__start__gte=start, + ) + for attendance_course in attendance_courses: + 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) + if not attendance_courses: + logger.info("No attendance courses found") + + +@click.command() +def command(): + check_attendance_course() diff --git a/server/vbv_lernwelt/notify/service.py b/server/vbv_lernwelt/notify/service.py index b0c07b79..2f0ea02e 100644 --- a/server/vbv_lernwelt/notify/service.py +++ b/server/vbv_lernwelt/notify/service.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import Optional import structlog @@ -11,35 +12,60 @@ 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, verb: str, target_url) -> bool: + def send_email( + cls, + recipient: User, + template: EmailTemplate, + template_data: dict, + ) -> None: message = Mail( - from_email="info@iterativ.ch", + from_email="noreply@my.vbv-afa.ch", to_emails=recipient.email, - subject=f"myVBV - {verb}", - ## TODO: Add HTML content. - html_content=f"{verb}: Link", ) - try: - cls._sendgrid_client.send(message) - logger.info(f"Successfully sent email to {recipient}", label="email") - return True - except Exception as e: - logger.error( - f"Failed to send email to {recipient}: {e}", - exc_info=True, - label="email", - ) - return False + 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( - cls, recipient: User, verb: str, sender: User, course: str, target_url: str + cls, + recipient: User, + verb: str, + sender: User, + course: str, + target_url: str, + email_template: EmailTemplate, + template_data: dict = {}, ) -> None: cls._send_notification( recipient=recipient, @@ -48,11 +74,19 @@ class NotificationService: course=course, target_url=target_url, notification_type=NotificationType.USER_INTERACTION, + email_template=email_template, + template_data=template_data, ) @classmethod def send_progress_notification( - cls, recipient: User, verb: str, course: str, target_url: str + cls, + recipient: User, + verb: str, + course: str, + target_url: str, + email_template: EmailTemplate, + template_data: dict = {}, ) -> None: cls._send_notification( recipient=recipient, @@ -60,17 +94,26 @@ class NotificationService: course=course, target_url=target_url, notification_type=NotificationType.PROGRESS, + email_template=email_template, + template_data=template_data, ) @classmethod def send_information_notification( - cls, recipient: User, verb: str, target_url: str + cls, + recipient: User, + verb: str, + target_url: str, + email_template: EmailTemplate, + template_data: dict = {}, ) -> None: cls._send_notification( recipient=recipient, verb=verb, target_url=target_url, notification_type=NotificationType.INFORMATION, + email_template=email_template, + template_data=template_data, ) @classmethod @@ -79,42 +122,66 @@ class NotificationService: recipient: User, verb: str, notification_type: NotificationType, - sender: Optional[User] = None, - course: Optional[str] = None, - target_url: Optional[str] = None, + email_template: EmailTemplate, + template_data: dict, + sender: User | None = None, + course: str | None = None, + target_url: str | None = None, ) -> None: actor_avatar_url: Optional[str] = None if not sender: sender = User.objects.get(email="admin") else: actor_avatar_url = sender.avatar_url - emailed = False - if cls._should_send_email(notification_type, recipient): - emailed = cls._send_email(recipient, verb, target_url) log = logger.bind( + recipient=recipient.get_full_name(), + sender=sender.get_full_name(), verb=verb, notification_type=notification_type, - sender=sender.get_full_name(), - recipient=recipient.get_full_name(), course=course, target_url=target_url, + template_data=template_data, ) + if NotificationService._is_duplicate_notification( + recipient=recipient, + verb=verb, + notification_type=notification_type, + target_url=target_url, + template_name=email_template.name, + template_data=template_data, + ): + log.warn("A duplicate notification was omitted from being sent") + return + + emailed = False + if cls._should_send_email(notification_type, recipient): + emailed = cls._send_email( + recipient=recipient, + template=email_template, + template_data={ + "target_url": f"https://my.vbv-afa.ch{target_url}", + **template_data, + }, + ) + try: response = notify.send( sender=sender, recipient=recipient, verb=verb, + emailed=emailed, + # The metadata is saved in the 'data' member of the AbstractNotification model + email_template=email_template.name, + template_data=template_data, ) - sent_notification: Notification = response[0][1][0] + sent_notification: Notification = response[0][1][0] # 🫨 sent_notification.target_url = target_url sent_notification.notification_type = notification_type sent_notification.course = course sent_notification.actor_avatar_url = actor_avatar_url - sent_notification.emailed = emailed sent_notification.save() except Exception as e: - log.bind(exception=e) - log.error("Failed to send notification") + log.error("Failed to send notification", exception=str(e)) else: log.info("Notification sent successfully") @@ -127,13 +194,49 @@ class NotificationService: ) @staticmethod - def _send_email(recipient: User, verb: str, target_url: Optional[str]) -> bool: + def _send_email( + recipient: User, + template: EmailTemplate, + template_data: dict | None, + ) -> bool: + log = logger.bind( + recipient=recipient.username, + template=template.name, + template_data=template_data, + ) try: - return EmailService.send_email( + EmailService.send_email( recipient=recipient, - verb=verb, - target_url=target_url, + template=template, + template_data=template_data, ) + log.info("Email sent successfully") + return True except Exception as e: - logger.error(f"Failed to send email to {recipient}: {e}") + log.error( + "Failed to send Email", exception=str(e), exc_info=True, stack_info=True + ) return False + + @staticmethod + def _is_duplicate_notification( + recipient: User, + verb: str, + notification_type: NotificationType, + template_name: str, + template_data: dict, + target_url: str | None, + ) -> bool: + """Check if a notification with the same parameters has already been sent to the recipient. + This is to prevent duplicate notifications from being sent and to protect against potential programming errors. + """ + return Notification.objects.filter( + recipient=recipient, + verb=verb, + notification_type=notification_type, + target_url=target_url, + data={ + "email_template": template_name, + "template_data": template_data, + }, + ).exists() 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 new file mode 100644 index 00000000..e618d6fb --- /dev/null +++ b/server/vbv_lernwelt/notify/tests/test_send_attendance_course_reminders.py @@ -0,0 +1,125 @@ +from dataclasses import dataclass +from datetime import datetime, timedelta +from unittest.mock import patch + +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 vbv_lernwelt.core.constants import TEST_COURSE_SESSION_BERN_ID +from vbv_lernwelt.core.create_default_users import create_default_users +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.management.commands.send_attendance_course_reminders import ( + check_attendance_course, +) +from vbv_lernwelt.notify.service import EmailTemplate + + +@dataclass +class SentNotification: + recipient: User + verb: str + target_url: str + email_template: EmailTemplate + template_data: dict + + +sent_notifications: list[SentNotification] = [] + + +def on_send_notification(**kwargs) -> None: + sent_notifications.append(SentNotification(**kwargs)) + + +@patch( + "vbv_lernwelt.notify.service.NotificationService.send_information_notification", + on_send_notification, +) +class TestAttendanceCourseReminders(TestCase): + def setUp(self): + create_default_users() + create_test_course(with_sessions=True) + CourseSessionAttendanceCourse.objects.all().delete() + + cs_bern = CourseSession.objects.get( + id=TEST_COURSE_SESSION_BERN_ID, + ) + self.csac = CourseSessionAttendanceCourse.objects.create( + course_session=cs_bern, + learning_content=LearningContentAttendanceCourse.objects.get( + slug="test-lehrgang-lp-circle-fahrzeug-lc-präsenzkurs-fahrzeug" + ), + location="Handelsschule KV Bern, Zimmer 123, Eigerstrasse 16, 3012 Bern", + trainer="Roland Grossenbacher", + ) + in_one_week = datetime.now() + timedelta(weeks=1) + self.csac.due_date.start = timezone.make_aware( + in_one_week.replace(hour=7, minute=30, second=0, microsecond=0) + ) + + self.csac.due_date.end = timezone.make_aware( + in_one_week.replace(hour=16, minute=15, second=0, microsecond=0) + ) + self.csac.due_date.save() + + # Attendance course more than two weeks in the future + self.csac_future = CourseSessionAttendanceCourse.objects.create( + course_session=cs_bern, + learning_content=LearningContentAttendanceCourse.objects.get( + slug="test-lehrgang-lp-circle-fahrzeug-lc-präsenzkurs-fahrzeug" + ), + location="Handelsschule BV Bern, Zimmer 122", + trainer="Thomas Berger", + ) + 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) + ) + self.csac_future.due_date.end = timezone.make_aware( + in_two_weeks.replace(hour=15, minute=20, second=0, microsecond=0) + ) + self.csac_future.due_date.save() + + def test_happy_day(self): + check_attendance_course() + self.assertEquals(3, len(sent_notifications)) + recipients = CourseSessionUser.objects.filter( + course_session_id=self.csac.course_session.id + ) + self.assertEquals( + 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"), + notification.verb, + ) + self.assertEquals( + "/course/test-lehrgang/learn/fahrzeug/präsenzkurs-fahrzeug", + notification.target_url, + ) + self.assertEquals( + self.csac.learning_content.title, + notification.template_data["attendance_course"], + ) + self.assertEquals( + self.csac.location, + notification.template_data["location"], + ) + self.assertEquals( + self.csac.trainer, + notification.template_data["trainer"], + ) + self.assertEquals( + self.csac.due_date.start.strftime("%H:%M %d.%m.%Y"), + notification.template_data["start"], + ) + self.assertEquals( + self.csac.due_date.end.strftime("%H:%M %d.%m.%Y"), + 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 b98f51b0..423245a1 100644 --- a/server/vbv_lernwelt/notify/tests/test_service.py +++ b/server/vbv_lernwelt/notify/tests/test_service.py @@ -1,18 +1,25 @@ import json -from typing import List from django.test import TestCase from vbv_lernwelt.core.models import User from vbv_lernwelt.core.tests.factories import UserFactory from vbv_lernwelt.notify.models import Notification, NotificationType -from vbv_lernwelt.notify.service import NotificationService +from vbv_lernwelt.notify.service import EmailTemplate, NotificationService class TestNotificationService(TestCase): + def _on_send_email(self, *_, **__) -> bool: + self._emails_sent += 1 + return True + + def _has_sent_emails(self) -> bool: + return self._emails_sent != 0 + def setUp(self) -> None: + self._emails_sent = 0 self.notification_service = NotificationService - self.notification_service._send_email = lambda a, b, c: True + self.notification_service._send_email = self._on_send_email self.admin = UserFactory(username="admin", email="admin") self.sender_username = "Bob" @@ -36,11 +43,10 @@ class TestNotificationService(TestCase): recipient=self.recipient, verb=verb, target_url=target_url, + email_template=EmailTemplate.ATTENDANCE_COURSE_REMINDER, ) - - notifications: List[Notification] = Notification.objects.all() - self.assertEqual(1, len(notifications)) - notification = notifications[0] + self.assertEqual(1, Notification.objects.count()) + notification: Notification = Notification.objects.first() self.assertEqual(self.admin, notification.actor) self.assertEqual(verb, notification.verb) self.assertEqual(target_url, notification.target_url) @@ -54,12 +60,14 @@ class TestNotificationService(TestCase): target_url = "https://www.vbv.ch" course = "Versicherungsvermittler/in" self.notification_service.send_progress_notification( - recipient=self.recipient, verb=verb, target_url=target_url, course=course + recipient=self.recipient, + verb=verb, + target_url=target_url, + course=course, + email_template=EmailTemplate.ATTENDANCE_COURSE_REMINDER, ) - - notifications: List[Notification] = Notification.objects.all() - self.assertEqual(1, len(notifications)) - notification = notifications[0] + self.assertEqual(1, Notification.objects.count()) + notification: Notification = Notification.objects.first() self.assertEqual(self.admin, notification.actor) self.assertEqual(verb, notification.verb) self.assertEqual(target_url, notification.target_url) @@ -77,11 +85,10 @@ class TestNotificationService(TestCase): verb=verb, target_url=target_url, course=course, + email_template=EmailTemplate.ATTENDANCE_COURSE_REMINDER, ) - - notifications: List[Notification] = Notification.objects.all() - self.assertEqual(1, len(notifications)) - notification = notifications[0] + self.assertEqual(1, Notification.objects.count()) + notification: Notification = Notification.objects.first() self.assertEqual(self.sender, notification.actor) self.assertEqual(verb, notification.verb) self.assertEqual(target_url, notification.target_url) @@ -90,3 +97,68 @@ class TestNotificationService(TestCase): str(NotificationType.USER_INTERACTION), notification.notification_type ) self.assertTrue(notification.emailed) + + def test_does_not_send_duplicate_notification(self): + verb = "Anne hat deinen Auftrag bewertet" + target_url = "https://www.vbv.ch" + course = "Versicherungsvermittler/in" + + for i in range(2): + self.notification_service.send_user_interaction_notification( + sender=self.sender, + recipient=self.recipient, + verb=verb, + target_url=target_url, + course=course, + email_template=EmailTemplate.ATTENDANCE_COURSE_REMINDER, + template_data={ + "blah": 123, + "foo": "ich habe hunger", + }, + ) + + self.assertEqual(1, Notification.objects.count()) + notification: Notification = Notification.objects.first() + self.assertEqual(self.sender, notification.actor) + self.assertEqual(verb, notification.verb) + self.assertEqual(target_url, notification.target_url) + self.assertEqual(course, notification.course) + self.assertEqual( + str(NotificationType.USER_INTERACTION), notification.notification_type + ) + self.assertTrue(notification.emailed) + + def test_only_sends_email_if_enabled(self): + # Assert no mail is sent if corresponding email notification type is not enabled + self.recipient.additional_json_data["email_notification_types"] = json.dumps( + ["INFORMATION"] + ) + self.recipient.save() + self.notification_service.send_user_interaction_notification( + sender=self.sender, + recipient=self.recipient, + verb="should not be sent", + target_url="", + course="", + email_template=EmailTemplate.CASEWORK_EVALUATED, + template_data={}, + ) + self.assertEqual(1, Notification.objects.count()) + self.assertFalse(self._has_sent_emails()) + + # Assert mail is sent if corresponding email notification type is enabled + self.recipient.additional_json_data["email_notification_types"] = json.dumps( + ["USER_INTERACTION"] + ) + self.recipient.save() + self.notification_service.send_user_interaction_notification( + sender=self.sender, + recipient=self.recipient, + verb="should be sent", + target_url="", + course="", + email_template=EmailTemplate.CASEWORK_EVALUATED, + template_data={}, + ) + self.assertEqual(2, Notification.objects.count()) + self.assertTrue(self._has_sent_emails()) diff --git a/trufflehog-allow.json b/trufflehog-allow.json index ec85115f..649d7e2f 100644 --- a/trufflehog-allow.json +++ b/trufflehog-allow.json @@ -11,5 +11,12 @@ "img base64 content": "regex:data:image/png;base64,.*", "sentry url": "https://2df6096a4fd94bd6b4802124d10e4b8d@o8544.ingest.sentry.io/4504157846372352", "git commit": "bdadf52b849bb5fa47854a3094f4da6fe9d54d02", - "customDomainVerificationId": "A2AB57353045150ADA4488FAA8AA9DFBBEDDD311934653F55243B336C2F3358E" + "customDomainVerificationId": "A2AB57353045150ADA4488FAA8AA9DFBBEDDD311934653F55243B336C2F3358E", + "SENDGRID_TEMPLATE_ATTENDANCE_COURSE_REMINDER_DE": "d-9af079f98f524d85ac6e4166de3480da", + "SENDGRID_TEMPLATE_ATTENDANCE_COURSE_REMINDER_FR": "d-f88d9912e5484e55a879571463e4a166", + "SENDGRID_TEMPLATE_ATTENDANCE_COURSE_REMINDER_IT": "d-ab78ddca8a7a46b8afe50aaba3efee81", + "SENDGRID_TEMPLATE_CASEWORK_SUBMITTED_DE": "d-599f0b35ddcd4fac99314cdf8f5446a2", + "SENDGRID_TEMPLATE_CASEWORK_EVALUATED_DE": "d-8c57fa13116b47be8eec95dfaf2aa030", + "SENDGRID_TEMPLATE_NEW_FEEDBACK_DE": "d-40fb94d5149949e7b8e7ddfcf0fcfdde", + "SUPERCRONIC_SHA1SUM": "7a79496cf8ad899b99a719355d4db27422396735" }