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/client/src/components/notifications/NotificationList.vue b/client/src/components/notifications/NotificationList.vue index 8d5bd404..e13d5104 100644 --- a/client/src/components/notifications/NotificationList.vue +++ b/client/src/components/notifications/NotificationList.vue @@ -56,7 +56,7 @@ function onNotificationClick(notification: Notification) { >
Notification icon{}".format(json.dumps(parsed, indent=4, sort_keys=True)) + ) + # pylint: disable=broad-except + except Exception: + return json_string 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/course_session/migrations/0005_auto_20230825_1723.py b/server/vbv_lernwelt/course_session/migrations/0005_auto_20230825_1723.py new file mode 100644 index 00000000..5840c77a --- /dev/null +++ b/server/vbv_lernwelt/course_session/migrations/0005_auto_20230825_1723.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.20 on 2023-08-25 15:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("course_session", "0004_coursesessionedoniqtest"), + ] + + operations = [ + migrations.AlterModelOptions( + name="coursesessionassignment", + options={"ordering": ["course_session", "submission_deadline__start"]}, + ), + migrations.AlterModelOptions( + name="coursesessionattendancecourse", + options={"ordering": ["course_session", "due_date__start"]}, + ), + migrations.AlterModelOptions( + name="coursesessionedoniqtest", + options={"ordering": ["course_session", "deadline__start"]}, + ), + ] diff --git a/server/vbv_lernwelt/feedback/models.py b/server/vbv_lernwelt/feedback/models.py index f9c0aaf1..cbc41a0d 100644 --- a/server/vbv_lernwelt/feedback/models.py +++ b/server/vbv_lernwelt/feedback/models.py @@ -1,12 +1,15 @@ import uuid +import structlog from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models 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.services import NotificationService + +logger = structlog.get_logger(__name__) class FeedbackIntegerField(models.IntegerField): @@ -45,19 +48,31 @@ class FeedbackResponse(models.Model): HUNDRED = 100, "100%" def save(self, *args, **kwargs): - if self._state.adding: - # with `id=UUIDField` it is always set... - course_session_users = CourseSessionUser.objects.filter( - role="EXPERT", course_session=self.course_session, expert=self.circle - ) - for csu in course_session_users: - NotificationService.send_information_notification( - 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}/", - ) + # with `id=UUIDField` it is always set... + create_new = self._state.adding + super(FeedbackResponse, self).save(*args, **kwargs) + try: + if create_new: + # with `id=UUIDField` it is always set... + course_session_users = CourseSessionUser.objects.filter( + role="EXPERT", + course_session=self.course_session, + expert=self.circle, + ) + for csu in course_session_users: + NotificationService.send_new_feedback_notification( + recipient=csu.user, + feedback_response=self, + ) + except Exception: + logger.exception( + "Failed to send feedback notification", + exc_info=True, + label="feedback_notification", + ) + data = models.JSONField(default=dict) created_at = models.DateTimeField(auto_now_add=True) diff --git a/server/vbv_lernwelt/feedback/tests/test_feedback_api.py b/server/vbv_lernwelt/feedback/tests/test_feedback_api.py index 68d2b3f0..020f0fc7 100644 --- a/server/vbv_lernwelt/feedback/tests/test_feedback_api.py +++ b/server/vbv_lernwelt/feedback/tests/test_feedback_api.py @@ -8,7 +8,11 @@ from vbv_lernwelt.course.models import CourseSession, CourseSessionUser from vbv_lernwelt.feedback.factories import FeedbackResponseFactory from vbv_lernwelt.feedback.models import FeedbackResponse from vbv_lernwelt.learnpath.models import Circle -from vbv_lernwelt.notify.models import Notification +from vbv_lernwelt.notify.models import ( + Notification, + NotificationCategory, + NotificationTrigger, +) class FeedbackApiBaseTestCase(APITestCase): @@ -62,20 +66,28 @@ class FeedbackSummaryApiTestCase(FeedbackApiBaseTestCase): basis_circle = Circle.objects.get(slug="test-lehrgang-lp-circle-reisen") csu.expert.add(basis_circle) - FeedbackResponse.objects.create( + feedback = FeedbackResponse.objects.create( circle=basis_circle, course_session=csu.course_session ) - notifications = Notification.objects.all() - self.assertEqual(len(notifications), 1) - self.assertEqual(notifications[0].recipient, expert) + self.assertEqual(Notification.objects.count(), 1) + notification = Notification.objects.first() + self.assertEqual(notification.recipient, expert) self.assertEqual( - notifications[0].verb, f"New feedback for circle {basis_circle.title}" + notification.verb, f"New feedback for circle {basis_circle.title}" ) self.assertEqual( - notifications[0].target_url, + notification.target_url, f"/course/{self.course_session.course.slug}/cockpit/feedback/{basis_circle.id}/", ) + self.assertEqual( + notification.notification_category, NotificationCategory.INFORMATION + ) + self.assertEqual( + notification.notification_trigger, NotificationTrigger.NEW_FEEDBACK + ) + self.assertEqual(notification.action_object, feedback) + self.assertEqual(notification.course_session, csu.course_session) def test_triggers_notification_only_on_create(self): expert = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch") diff --git a/server/vbv_lernwelt/importer/services.py b/server/vbv_lernwelt/importer/services.py index 3e9802c1..935e6f1d 100644 --- a/server/vbv_lernwelt/importer/services.py +++ b/server/vbv_lernwelt/importer/services.py @@ -3,7 +3,6 @@ from typing import Any, Dict, List import structlog from django.utils import timezone -from openpyxl.reader.excel import load_workbook from vbv_lernwelt.assignment.models import AssignmentType from vbv_lernwelt.core.models import User @@ -25,6 +24,7 @@ from vbv_lernwelt.learnpath.models import ( LearningContentAttendanceCourse, LearningContentEdoniqTest, ) +from vbv_lernwelt.notify.models import NotificationCategory logger = structlog.get_logger(__name__) @@ -248,7 +248,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 @@ -270,6 +274,8 @@ def import_course_sessions_from_excel( "Basis", "Fahrzeug", ] + from openpyxl.reader.excel import load_workbook + workbook = load_workbook(filename=filename) sheet = workbook["Schulungen Durchführung"] no_course = course is None @@ -516,6 +522,8 @@ def get_uk_course(language: str) -> Course: def import_trainers_from_excel_for_training( filename: str, language="de", course: Course = None ): + from openpyxl.reader.excel import load_workbook + workbook = load_workbook(filename=filename) sheet = workbook["Schulungen Trainer"] @@ -604,6 +612,8 @@ def create_or_update_trainer(course: Course, data: Dict[str, Any], language="de" def import_students_from_excel(filename: str): + from openpyxl.reader.excel import load_workbook + workbook = load_workbook(filename=filename) sheet = workbook.active @@ -666,6 +676,8 @@ def _get_date_of_birth(data: Dict[str, Any]) -> str: def sync_students_from_t2l_excel(filename: str): + from openpyxl.reader.excel import load_workbook + workbook = load_workbook(filename=filename) sheet = workbook.active @@ -699,8 +711,12 @@ 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_categories": [str(NotificationCategory.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..6bf3f63a 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_categories": ["INFORMATION"], } def test_create_student(self): diff --git a/server/vbv_lernwelt/notify/admin.py b/server/vbv_lernwelt/notify/admin.py new file mode 100644 index 00000000..e8663834 --- /dev/null +++ b/server/vbv_lernwelt/notify/admin.py @@ -0,0 +1,32 @@ +from vbv_lernwelt.core.admin import LogAdmin + + +# admin.register in apps.py +class CustomNotificationAdmin(LogAdmin): + date_hierarchy = "timestamp" + raw_id_fields = ("recipient",) + search_fields = (("recipient__username"),) + list_display = ( + "timestamp", + "recipient", + "actor", + "notification_category", + "notification_trigger", + "course_session", + "emailed", + "unread", + ) + list_filter = ( + "notification_category", + "notification_trigger", + "emailed", + "unread", + "timestamp", + # "level", + # "public", + "course_session", + ) + + def get_queryset(self, request): + qs = super(CustomNotificationAdmin, self).get_queryset(request) + return qs.prefetch_related("actor") diff --git a/server/vbv_lernwelt/notify/apps.py b/server/vbv_lernwelt/notify/apps.py index 59aae618..4212965a 100644 --- a/server/vbv_lernwelt/notify/apps.py +++ b/server/vbv_lernwelt/notify/apps.py @@ -1,6 +1,20 @@ from django.apps import AppConfig +from django.contrib import admin class NotifyConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "vbv_lernwelt.notify" + + def ready(self): + # Unregister the default Notification admin if it exists + from vbv_lernwelt.notify.models import Notification + + # Move the admin import here to avoid early imports + from .admin import CustomNotificationAdmin + + if admin.site.is_registered(Notification): + admin.site.unregister(Notification) + + # Register the custom admin + admin.site.register(Notification, CustomNotificationAdmin) diff --git a/server/vbv_lernwelt/notify/create_default_notifications.py b/server/vbv_lernwelt/notify/create_default_notifications.py index d4a16b1c..c999ad45 100644 --- a/server/vbv_lernwelt/notify/create_default_notifications.py +++ b/server/vbv_lernwelt/notify/create_default_notifications.py @@ -3,7 +3,7 @@ from datetime import timedelta from django.utils import timezone from vbv_lernwelt.core.admin import User -from vbv_lernwelt.notify.models import NotificationType +from vbv_lernwelt.notify.models import NotificationCategory from vbv_lernwelt.notify.tests.factories import NotificationFactory @@ -28,8 +28,7 @@ def create_default_notifications() -> int: verb="Alexandra hat einen neuen Beitrag erfasst", actor_avatar_url=avatar_urls[0], target_url="/", - notification_type=NotificationType.USER_INTERACTION, - course="Versicherungsvermittler/-in", + notification_category=NotificationCategory.USER_INTERACTION, timestamp=timestamps[0], ), NotificationFactory( @@ -38,8 +37,7 @@ def create_default_notifications() -> int: verb="Alexandra hat einen neuen Beitrag erfasst", actor_avatar_url=avatar_urls[0], target_url="/", - notification_type=NotificationType.USER_INTERACTION, - course="Versicherungsvermittler/-in", + notification_category=NotificationCategory.USER_INTERACTION, timestamp=timestamps[1], ), NotificationFactory( @@ -48,8 +46,7 @@ def create_default_notifications() -> int: verb="Alexandra hat einen neuen Beitrag erfasst", actor_avatar_url=avatar_urls[0], target_url="/", - notification_type=NotificationType.USER_INTERACTION, - course="Versicherungsvermittler/-in", + notification_category=NotificationCategory.USER_INTERACTION, timestamp=timestamps[2], ), NotificationFactory( @@ -58,8 +55,7 @@ def create_default_notifications() -> int: verb="Alexandra hat einen neuen Beitrag erfasst", actor_avatar_url=avatar_urls[0], target_url="/", - notification_type=NotificationType.USER_INTERACTION, - course="Versicherungsvermittler/-in", + notification_category=NotificationCategory.USER_INTERACTION, timestamp=timestamps[3], ), NotificationFactory( @@ -68,8 +64,7 @@ def create_default_notifications() -> int: verb="Bianca hat für den Auftrag Autoversicherung 3 eine Lösung abgegeben", actor_avatar_url=avatar_urls[1], target_url="/", - notification_type=NotificationType.USER_INTERACTION, - course="Versicherungsvermittler/-in", + notification_category=NotificationCategory.USER_INTERACTION, timestamp=timestamps[4], ), NotificationFactory( @@ -78,8 +73,7 @@ def create_default_notifications() -> int: verb="Bianca hat für den Auftrag Autoversicherung 1 eine Lösung abgegeben", actor_avatar_url=avatar_urls[1], target_url="/", - notification_type=NotificationType.USER_INTERACTION, - course="Versicherungsvermittler/-in", + notification_category=NotificationCategory.USER_INTERACTION, timestamp=timestamps[5], ), NotificationFactory( @@ -88,8 +82,7 @@ def create_default_notifications() -> int: verb="Bianca hat für den Auftrag Autoversicherung 2 eine Lösung abgegeben", actor_avatar_url=avatar_urls[1], target_url="/", - notification_type=NotificationType.USER_INTERACTION, - course="Versicherungsvermittler/-in", + notification_category=NotificationCategory.USER_INTERACTION, timestamp=timestamps[6], ), NotificationFactory( @@ -98,8 +91,7 @@ def create_default_notifications() -> int: verb="Bianca hat für den Auftrag Autoversicherung 4 eine Lösung abgegeben", actor_avatar_url=avatar_urls[1], target_url="/", - notification_type=NotificationType.USER_INTERACTION, - course="Versicherungsvermittler/-in", + notification_category=NotificationCategory.USER_INTERACTION, timestamp=timestamps[7], ), NotificationFactory( @@ -108,8 +100,7 @@ def create_default_notifications() -> int: verb="Chantal hat eine Bewertung für den Transferauftrag 3 eingegeben", target_url="/", actor_avatar_url=avatar_urls[2], - notification_type=NotificationType.USER_INTERACTION, - course="Versicherungsvermittler/-in", + notification_category=NotificationCategory.USER_INTERACTION, timestamp=timestamps[8], ), NotificationFactory( @@ -118,8 +109,7 @@ def create_default_notifications() -> int: verb="Chantal hat eine Bewertung für den Transferauftrag 4 eingegeben", target_url="/", actor_avatar_url=avatar_urls[2], - notification_type=NotificationType.USER_INTERACTION, - course="Versicherungsvermittler/-in", + notification_category=NotificationCategory.USER_INTERACTION, timestamp=timestamps[9], ), NotificationFactory( @@ -127,22 +117,21 @@ def create_default_notifications() -> int: actor=user, verb="Super, du kommst in deinem Lernpfad gut voran. Schaue dir jetzt die verfügbaren Prüfungstermine an.", target_url="/", - notification_type=NotificationType.PROGRESS, - course="Versicherungsvermittler/-in", + notification_category=NotificationCategory.PROGRESS, timestamp=timestamps[10], ), NotificationFactory( recipient=user, actor=user, verb="Wartungsarbeiten: 20.12.2022 08:00 - 12:00", - notification_type=NotificationType.INFORMATION, + notification_category=NotificationCategory.INFORMATION, timestamp=timestamps[11], ), NotificationFactory( recipient=user, actor=user, verb="Wartungsarbeiten: 31.01.2023 08:00 - 12:00", - notification_type=NotificationType.INFORMATION, + notification_category=NotificationCategory.INFORMATION, timestamp=timestamps[12], ), ) 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..d62d3421 --- /dev/null +++ b/server/vbv_lernwelt/notify/email/email_services.py @@ -0,0 +1,105 @@ +from enum import Enum + +import structlog +from constance import config +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( + recipient_email: str, + template: EmailTemplate, + template_data: dict, + template_language: str = "de", + fail_silently: bool = True, +) -> bool: + log = logger.bind( + recipient_email=recipient_email, + template=template.name, + template_data=template_data, + template_language=template_language, + ) + try: + whitelist_emails = [ + email.strip() + for email in config.EMAIL_RECIPIENT_WHITELIST.strip().split(",") + ] + if "*" in whitelist_emails or recipient_email in whitelist_emails: + _send_sendgrid_email( + recipient_email=recipient_email, + template=template, + template_data=template_data, + template_language=template_language, + ) + log.info("Email sent successfully") + return True + else: + log.info("Email not sent because recipient is not whitelisted") + return False + 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( + recipient_email: str, + template: EmailTemplate, + template_data: dict, + template_language: str = "de", +) -> None: + message = Mail( + from_email="noreply@my.vbv-afa.ch", + to_emails=recipient_email, + ) + 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 new file mode 100644 index 00000000..81e89677 --- /dev/null +++ b/server/vbv_lernwelt/notify/management/commands/send_attendance_course_reminders.py @@ -0,0 +1,69 @@ +from collections import Counter +from datetime import timedelta + +import structlog +from django.utils import timezone + +from vbv_lernwelt.core.base import LoggedCommand +from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse +from vbv_lernwelt.notify.services import NotificationService + +logger = structlog.get_logger(__name__) + +PRESENCE_COURSE_REMINDER_LEAD_TIME = timedelta(weeks=2) + + +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 + results_counter = Counter() + attendance_courses = CourseSessionAttendanceCourse.objects.filter( + due_date__start__lte=end, + due_date__start__gte=start, + ) + + logger.info( + "Querying for attendance courses in specified time range", + start_time=start, + end_time=end, + label="attendance_course_reminder_notification_job", + num_attendance_courses=len(attendance_courses), + ) + for attendance_course in attendance_courses: + cs_id = attendance_course.course_session.id + csu = CourseSessionUser.objects.filter(course_session_id=cs_id) + logger.info( + "Sending attendance course reminder notification", + start_time=start, + end_time=end, + label="attendance_course_reminder_notification_job", + num_users=len(csu), + course_session_id=cs_id, + ) + for user in csu: + result = NotificationService.send_attendance_course_reminder_notification( + user.user, attendance_course + ) + results_counter[result] += 1 + if not attendance_courses: + logger.info( + "No attendance courses found", + label="attendance_course_reminder_notification_job", + ) + return dict(results_counter) + + +class Command(LoggedCommand): + help = "Sends attendance course reminder notifications to participants" + + def handle(self, *args, **options): + results = attendance_course_reminder_notification_job() + self.job_log.json_data = results + self.job_log.save() + logger.info( + "Attendance course reminder notification job finished", + label="attendance_course_reminder_notification_job", + results=results, + ) diff --git a/server/vbv_lernwelt/notify/migrations/0002_auto_20230830_1606.py b/server/vbv_lernwelt/notify/migrations/0002_auto_20230830_1606.py new file mode 100644 index 00000000..d4292aa4 --- /dev/null +++ b/server/vbv_lernwelt/notify/migrations/0002_auto_20230830_1606.py @@ -0,0 +1,69 @@ +# Generated by Django 3.2.20 on 2023-08-30 14:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("course", "0004_auto_20230823_1744"), + ("notify", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="notification", + name="course", + ), + migrations.RemoveField( + model_name="notification", + name="notification_type", + ), + migrations.AddField( + model_name="notification", + name="course_session", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="course.coursesession", + ), + ), + migrations.AddField( + model_name="notification", + name="notification_category", + field=models.CharField( + choices=[ + ("USER_INTERACTION", "User Interaction"), + ("PROGRESS", "Progress"), + ("INFORMATION", "Information"), + ], + default="INFORMATION", + max_length=255, + ), + ), + migrations.AddField( + model_name="notification", + name="notification_trigger", + field=models.CharField( + choices=[ + ("ATTENDANCE_COURSE_REMINDER", "Attendance Course Reminder"), + ("CASEWORK_SUBMITTED", "Casework Submitted"), + ("CASEWORK_EVALUATED", "Casework Evaluated"), + ("NEW_FEEDBACK", "New Feedback"), + ], + default="ATTENDANCE_COURSE_REMINDER", + max_length=255, + ), + ), + migrations.AlterField( + model_name="notification", + name="actor_avatar_url", + field=models.CharField(blank=True, default="", max_length=2048), + ), + migrations.AlterField( + model_name="notification", + name="target_url", + field=models.CharField(blank=True, default="", max_length=2048), + ), + ] diff --git a/server/vbv_lernwelt/notify/migrations/0003_truncate_notifications.py b/server/vbv_lernwelt/notify/migrations/0003_truncate_notifications.py new file mode 100644 index 00000000..8751eb88 --- /dev/null +++ b/server/vbv_lernwelt/notify/migrations/0003_truncate_notifications.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.20 on 2023-08-30 14:10 + +from django.db import migrations + + +def init_user_notification_emails(apps=None, schema_editor=None): + User = apps.get_model("core", "User") + for u in User.objects.all(): + u.additional_json_data["email_notification_categories"] = ["NOTIFICATION"] + u.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("notify", "0002_auto_20230830_1606"), + ] + + operations = [ + migrations.RunSQL("truncate table notify_notification cascade;"), + migrations.RunPython(init_user_notification_emails), + ] diff --git a/server/vbv_lernwelt/notify/models.py b/server/vbv_lernwelt/notify/models.py index dd00ed99..dfe6df74 100644 --- a/server/vbv_lernwelt/notify/models.py +++ b/server/vbv_lernwelt/notify/models.py @@ -2,25 +2,43 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from notifications.base.models import AbstractNotification +from vbv_lernwelt.course.models import CourseSession -class NotificationType(models.TextChoices): + +class NotificationCategory(models.TextChoices): USER_INTERACTION = "USER_INTERACTION", _("User Interaction") PROGRESS = "PROGRESS", _("Progress") INFORMATION = "INFORMATION", _("Information") +class NotificationTrigger(models.TextChoices): + ATTENDANCE_COURSE_REMINDER = "ATTENDANCE_COURSE_REMINDER", _( + "Attendance Course Reminder" + ) + CASEWORK_SUBMITTED = "CASEWORK_SUBMITTED", _("Casework Submitted") + CASEWORK_EVALUATED = "CASEWORK_EVALUATED", _("Casework Evaluated") + NEW_FEEDBACK = "NEW_FEEDBACK", _("New Feedback") + + class Notification(AbstractNotification): # UUIDs are not supported by the notifications app... # id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - notification_type = models.CharField( - max_length=32, - choices=NotificationType.choices, - default=NotificationType.INFORMATION, + notification_category = models.CharField( + max_length=255, + choices=NotificationCategory.choices, + default=NotificationCategory.INFORMATION, + ) + notification_trigger = models.CharField( + max_length=255, + choices=NotificationTrigger.choices, + default="", + ) + target_url = models.CharField(max_length=2048, default="", blank=True) + actor_avatar_url = models.CharField(max_length=2048, default="", blank=True) + course_session = models.ForeignKey( + CourseSession, blank=True, null=True, on_delete=models.SET_NULL ) - target_url = models.URLField(blank=True, null=True) - actor_avatar_url = models.URLField(blank=True, null=True) - course = models.CharField(max_length=32, blank=True, null=True) class Meta(AbstractNotification.Meta): abstract = False diff --git a/server/vbv_lernwelt/notify/service.py b/server/vbv_lernwelt/notify/service.py deleted file mode 100644 index b0c07b79..00000000 --- a/server/vbv_lernwelt/notify/service.py +++ /dev/null @@ -1,139 +0,0 @@ -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.models import Notification, NotificationType - -logger = structlog.get_logger(__name__) - - -class EmailService: - _sendgrid_client = SendGridAPIClient(setting("SENDGRID_API_KEY")) - - @classmethod - def send_email(cls, recipient: User, verb: str, target_url) -> bool: - message = Mail( - from_email="info@iterativ.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 - - -class NotificationService: - @classmethod - def send_user_interaction_notification( - cls, recipient: User, verb: str, sender: User, course: str, target_url: str - ) -> None: - cls._send_notification( - recipient=recipient, - verb=verb, - sender=sender, - course=course, - target_url=target_url, - notification_type=NotificationType.USER_INTERACTION, - ) - - @classmethod - def send_progress_notification( - cls, recipient: User, verb: str, course: str, target_url: str - ) -> None: - cls._send_notification( - recipient=recipient, - verb=verb, - course=course, - target_url=target_url, - notification_type=NotificationType.PROGRESS, - ) - - @classmethod - def send_information_notification( - cls, recipient: User, verb: str, target_url: str - ) -> None: - cls._send_notification( - recipient=recipient, - verb=verb, - target_url=target_url, - notification_type=NotificationType.INFORMATION, - ) - - @classmethod - def _send_notification( - cls, - recipient: User, - verb: str, - notification_type: NotificationType, - sender: Optional[User] = None, - course: Optional[str] = None, - target_url: Optional[str] = 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( - verb=verb, - notification_type=notification_type, - sender=sender.get_full_name(), - recipient=recipient.get_full_name(), - course=course, - target_url=target_url, - ) - try: - response = notify.send( - sender=sender, - recipient=recipient, - verb=verb, - ) - 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") - else: - log.info("Notification sent successfully") - - @staticmethod - def _should_send_email( - notification_type: NotificationType, recipient: User - ) -> bool: - return str(notification_type) in recipient.additional_json_data.get( - "email_notification_types", [] - ) - - @staticmethod - def _send_email(recipient: User, verb: str, target_url: Optional[str]) -> bool: - try: - return EmailService.send_email( - recipient=recipient, - verb=verb, - target_url=target_url, - ) - except Exception as e: - logger.error(f"Failed to send email to {recipient}: {e}") - return False diff --git a/server/vbv_lernwelt/notify/services.py b/server/vbv_lernwelt/notify/services.py new file mode 100644 index 00000000..6cc2848e --- /dev/null +++ b/server/vbv_lernwelt/notify/services.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from gettext import gettext +from typing import TYPE_CHECKING + +import structlog +from django.db.models import Model +from notifications.signals import notify + +from vbv_lernwelt.core.models import User +from vbv_lernwelt.course.models import CourseSession +from vbv_lernwelt.notify.email.email_services import ( + create_template_data_from_course_session_attendance_course, + EmailTemplate, + send_email, +) +from vbv_lernwelt.notify.models import ( + Notification, + NotificationCategory, + NotificationTrigger, +) + +if TYPE_CHECKING: + from vbv_lernwelt.assignment.models import AssignmentCompletion + from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse + from vbv_lernwelt.feedback.models import FeedbackResponse + +logger = structlog.get_logger(__name__) + + +class NotificationService: + @classmethod + def send_assignment_submitted_notification( + cls, recipient: User, sender: User, assignment_completion: AssignmentCompletion + ): + verb = gettext( + "%(sender)s hat die geleitete Fallarbeit «%(assignment_title)s» abgegeben." + ) % { + "sender": sender.get_full_name(), + "assignment_title": assignment_completion.assignment.title, + } + + return cls._send_notification( + recipient=recipient, + verb=verb, + notification_category=NotificationCategory.USER_INTERACTION, + notification_trigger=NotificationTrigger.CASEWORK_SUBMITTED, + sender=sender, + target_url=assignment_completion.get_assignment_evaluation_frontend_url(), + course_session=assignment_completion.course_session, + action_object=assignment_completion, + email_template=EmailTemplate.CASEWORK_SUBMITTED, + ) + + @classmethod + def send_assignment_evaluated_notification( + cls, + recipient: User, + sender: User, + assignment_completion: AssignmentCompletion, + target_url: str, + ): + verb = gettext( + "%(sender)s hat die geleitete Fallarbeit «%(assignment_title)s» bewertet." + ) % { + "sender": sender.get_full_name(), + "assignment_title": assignment_completion.assignment.title, + } + + return cls._send_notification( + recipient=recipient, + verb=verb, + notification_category=NotificationCategory.USER_INTERACTION, + notification_trigger=NotificationTrigger.CASEWORK_EVALUATED, + sender=sender, + target_url=target_url, + course_session=assignment_completion.course_session, + action_object=assignment_completion, + email_template=EmailTemplate.CASEWORK_EVALUATED, + ) + + @classmethod + def send_new_feedback_notification( + cls, + recipient: User, + feedback_response: FeedbackResponse, + ): + verb = f"New feedback for circle {feedback_response.circle.title}" + + return cls._send_notification( + recipient=recipient, + verb=verb, + notification_category=NotificationCategory.INFORMATION, + notification_trigger=NotificationTrigger.NEW_FEEDBACK, + target_url=f"/course/{feedback_response.course_session.course.slug}/cockpit/feedback/{feedback_response.circle_id}/", + course_session=feedback_response.course_session, + action_object=feedback_response, + email_template=EmailTemplate.NEW_FEEDBACK, + ) + + @classmethod + def send_attendance_course_reminder_notification( + cls, + recipient: User, + attendance_course: CourseSessionAttendanceCourse, + ): + return cls._send_notification( + recipient=recipient, + verb="Erinnerung: Bald findet ein Präsenzkurs statt", + notification_category=NotificationCategory.INFORMATION, + notification_trigger=NotificationTrigger.ATTENDANCE_COURSE_REMINDER, + target_url=attendance_course.learning_content.get_frontend_url(), + action_object=attendance_course, + course_session=attendance_course.course_session, + email_template=EmailTemplate.ATTENDANCE_COURSE_REMINDER, + template_data=create_template_data_from_course_session_attendance_course( + attendance_course=attendance_course + ), + ) + + @classmethod + def _send_notification( + cls, + recipient: User, + verb: str, + notification_category: NotificationCategory, + notification_trigger: NotificationTrigger, + sender: User | None = None, + action_object: Model | None = None, + target_url: str | None = None, + course_session: CourseSession | None = None, + email_template: EmailTemplate | None = None, + template_data: dict | None = None, + fail_silently: bool = True, + ) -> str: + if template_data is None: + template_data = {} + + notification_identifier = ( + f"{notification_category.name}_{notification_trigger.name}" + ) + + actor_avatar_url = "" + if not sender: + sender = User.objects.get(email="admin") + else: + actor_avatar_url = sender.avatar_url + log = logger.bind( + recipient=recipient.email, + sender=sender.email, + verb=verb, + notification_category=notification_category, + notification_trigger=notification_trigger, + course_session=course_session.title if course_session else "", + target_url=target_url, + template_data=template_data, + ) + emailed = False + try: + notification = NotificationService._find_duplicate_notification( + recipient=recipient, + verb=verb, + notification_category=notification_category, + notification_trigger=notification_trigger, + target_url=target_url, + course_session=course_session, + ) + emailed = False + if notification and notification.emailed: + emailed = True + + if ( + email_template + and cls._should_send_email(notification_category, recipient) + and not emailed + ): + log.debug("Try to send email") + try: + emailed = cls._send_email( + recipient=recipient, + template=email_template, + template_data={ + "target_url": f"https://my.vbv-afa.ch{target_url}", + **template_data, + }, + fail_silently=False, + ) + except Exception as e: + notification_identifier += "_email_error" + if not fail_silently: + raise e + return notification_identifier + + if emailed: + notification_identifier += "_emailed" + if notification: + notification.emailed = True + notification.save() + else: + log.debug("Should not send email") + + if notification: + log.info("Duplicate notification was omitted from being sent") + notification_identifier += "_duplicate" + return notification_identifier + + else: + response = notify.send( + sender=sender, + recipient=recipient, + verb=verb, + action_object=action_object, + emailed=emailed, + # The extra arguments are saved in the 'data' member + email_template=email_template.name if email_template else "", + template_data=template_data, + ) + + sent_notification: Notification = response[0][1][0] # 🫨 + sent_notification.target_url = target_url + sent_notification.notification_category = notification_category + sent_notification.notification_trigger = notification_trigger + sent_notification.course_session = course_session + sent_notification.actor_avatar_url = actor_avatar_url + sent_notification.save() + log.info("Notification sent successfully", emailed=emailed) + return f"{notification_identifier}_success" + except Exception as e: + log.error( + "Failed to send notification", + exception=str(e), + exc_info=True, + stack_info=True, + emailed=emailed, + ) + if not fail_silently: + raise e + return f"{notification_identifier}_error" + + @staticmethod + def _should_send_email( + notification_category: NotificationCategory, recipient: User + ) -> bool: + return str(notification_category) in recipient.additional_json_data.get( + "email_notification_categories", [] + ) + + @staticmethod + def _send_email( + recipient: User, + template: EmailTemplate, + template_data: dict, + fail_silently: bool = True, + ) -> bool: + return send_email( + recipient_email=recipient.email, + template=template, + template_data=template_data, + template_language=recipient.language, + fail_silently=fail_silently, + ) + + @staticmethod + def _find_duplicate_notification( + recipient: User, + verb: str, + notification_category: NotificationCategory, + notification_trigger: NotificationTrigger, + target_url: str | None, + course_session: CourseSession | None, + ) -> Notification | None: + """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_category=notification_category, + notification_trigger=notification_trigger, + target_url=target_url, + course_session=course_session, + ).first() diff --git a/server/vbv_lernwelt/notify/tests/test_notify_api.py b/server/vbv_lernwelt/notify/tests/test_notify_api.py index fa9a2863..727841ab 100644 --- a/server/vbv_lernwelt/notify/tests/test_notify_api.py +++ b/server/vbv_lernwelt/notify/tests/test_notify_api.py @@ -4,7 +4,7 @@ from rest_framework.test import APITestCase from vbv_lernwelt.core.admin import User from vbv_lernwelt.core.tests.factories import UserFactory -from vbv_lernwelt.notify.models import Notification, NotificationType +from vbv_lernwelt.notify.models import Notification, NotificationCategory from vbv_lernwelt.notify.tests.factories import NotificationFactory @@ -115,7 +115,7 @@ class TestNotificationSettingsApi(APITestCase): def test_store_retrieve_settings(self): notification_settings = json.dumps( - [NotificationType.INFORMATION, NotificationType.PROGRESS] + [NotificationCategory.INFORMATION, NotificationCategory.PROGRESS] ) api_path = "/api/notify/email_notification_settings/" @@ -128,7 +128,7 @@ class TestNotificationSettingsApi(APITestCase): self.assertEqual(response.json(), notification_settings) self.user.refresh_from_db() self.assertEqual( - self.user.additional_json_data["email_notification_types"], + self.user.additional_json_data["email_notification_categories"], notification_settings, ) 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..de03f261 --- /dev/null +++ b/server/vbv_lernwelt/notify/tests/test_send_attendance_course_reminders.py @@ -0,0 +1,116 @@ +from datetime import datetime, timedelta + +from django.test import TestCase +from django.utils import timezone +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 +from vbv_lernwelt.course.creators.test_course import create_test_course +from vbv_lernwelt.course.models import CourseSession +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 ( + attendance_course_reminder_notification_job, +) +from vbv_lernwelt.notify.models import 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", + ) + ref_date_time = timezone.make_aware(datetime(2023, 8, 29)) + self.csac.due_date.start = ref_date_time.replace( + hour=7, minute=30, second=0, microsecond=0 + ) + self.csac.due_date.end = ref_date_time.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", + ) + + @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) + ) + 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() + + attendance_course_reminder_notification_job() + + self.assertEquals(3, len(Notification.objects.all())) + notification = Notification.objects.get( + recipient__username="test-student1@example.com" + ) + + self.assertEquals( + "Erinnerung: Bald findet ein Präsenzkurs statt", + notification.verb, + ) + self.assertEquals( + "INFORMATION", + notification.notification_category, + ) + self.assertEquals( + "ATTENDANCE_COURSE_REMINDER", + notification.notification_trigger, + ) + self.assertEquals( + self.csac, + notification.action_object, + ) + self.assertEquals( + self.csac.course_session, + notification.course_session, + ) + self.assertEquals( + "/course/test-lehrgang/learn/fahrzeug/präsenzkurs-fahrzeug", + notification.target_url, + ) + self.assertEquals( + self.csac.learning_content.title, + notification.data["template_data"]["attendance_course"], + ) + self.assertEquals( + self.csac.location, + notification.data["template_data"]["location"], + ) + self.assertEquals( + self.csac.trainer, + notification.data["template_data"]["trainer"], + ) + self.assertEquals( + self.csac.due_date.start.strftime("%d.%m.%Y %H:%M"), + notification.data["template_data"]["start"], + ) + self.assertEquals( + self.csac.due_date.end.strftime("%d.%m.%Y %H:%M"), + notification.data["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..6d8af677 100644 --- a/server/vbv_lernwelt/notify/tests/test_service.py +++ b/server/vbv_lernwelt/notify/tests/test_service.py @@ -1,92 +1,206 @@ 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.email.email_services import EmailTemplate +from vbv_lernwelt.notify.models import ( + Notification, + NotificationCategory, + NotificationTrigger, +) +from vbv_lernwelt.notify.services import 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" - UserFactory(username=self.sender_username, email="bob@gmail.com") + UserFactory(username=self.sender_username, email="bob@example.com") self.sender = User.objects.get(username=self.sender_username) self.recipient_username = "Alice" - UserFactory(username=self.recipient_username, email="alice@gmail.com") + UserFactory(username=self.recipient_username, email="alice@example.com") self.recipient = User.objects.get(username=self.recipient_username) - self.recipient.additional_json_data["email_notification_types"] = json.dumps( - ["USER_INTERACTION", "INFORMATION"] - ) self.recipient.save() self.client.login(username=self.recipient, password="pw") - def test_send_information_notification(self): - verb = "Wartungsarbeiten: 13.12 10:00 - 13:00 Uhr" - target_url = "https://www.vbv.ch" - self.notification_service.send_information_notification( - recipient=self.recipient, - verb=verb, - target_url=target_url, - ) - - notifications: List[Notification] = Notification.objects.all() - self.assertEqual(1, len(notifications)) - notification = notifications[0] - self.assertEqual(self.admin, notification.actor) - self.assertEqual(verb, notification.verb) - self.assertEqual(target_url, notification.target_url) - self.assertEqual( - str(NotificationType.INFORMATION), notification.notification_type - ) - self.assertTrue(notification.emailed) - - def test_send_progress_notification(self): - verb = "Super Fortschritt! Melde dich jetzt an." - 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 - ) - - notifications: List[Notification] = Notification.objects.all() - self.assertEqual(1, len(notifications)) - notification = notifications[0] - self.assertEqual(self.admin, notification.actor) - self.assertEqual(verb, notification.verb) - self.assertEqual(target_url, notification.target_url) - self.assertEqual(course, notification.course) - self.assertEqual(str(NotificationType.PROGRESS), notification.notification_type) - self.assertFalse(notification.emailed) - - def test_send_user_interaction_notification(self): + def test_send_notification_without_email(self): verb = "Anne hat deinen Auftrag bewertet" target_url = "https://www.vbv.ch" - course = "Versicherungsvermittler/in" - self.notification_service.send_user_interaction_notification( + result = self.notification_service._send_notification( sender=self.sender, recipient=self.recipient, verb=verb, target_url=target_url, - course=course, + notification_category=NotificationCategory.USER_INTERACTION, + notification_trigger=NotificationTrigger.CASEWORK_SUBMITTED, + fail_silently=False, ) - notifications: List[Notification] = Notification.objects.all() - self.assertEqual(1, len(notifications)) - notification = notifications[0] + self.assertEqual(result, "USER_INTERACTION_CASEWORK_SUBMITTED_success") + + 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 + str(NotificationCategory.USER_INTERACTION), + notification.notification_category, + ) + self.assertFalse(notification.emailed) + + def test_send_notification_with_email(self): + self.recipient.additional_json_data[ + "email_notification_categories" + ] = json.dumps(["USER_INTERACTION"]) + self.recipient.save() + + verb = "Anne hat deinen Auftrag bewertet" + target_url = "https://www.vbv.ch" + result = self.notification_service._send_notification( + sender=self.sender, + recipient=self.recipient, + verb=verb, + target_url=target_url, + notification_category=NotificationCategory.USER_INTERACTION, + notification_trigger=NotificationTrigger.CASEWORK_SUBMITTED, + email_template=EmailTemplate.CASEWORK_SUBMITTED, + fail_silently=False, + ) + + self.assertEqual(result, "USER_INTERACTION_CASEWORK_SUBMITTED_emailed_success") + + 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( + str(NotificationCategory.USER_INTERACTION), + notification.notification_category, ) self.assertTrue(notification.emailed) + + def test_does_not_send_duplicate_notification(self): + verb = "Anne hat deinen Auftrag bewertet" + target_url = "https://www.vbv.ch" + + result = self.notification_service._send_notification( + sender=self.sender, + recipient=self.recipient, + verb=verb, + target_url=target_url, + notification_category=NotificationCategory.USER_INTERACTION, + notification_trigger=NotificationTrigger.CASEWORK_SUBMITTED, + email_template=EmailTemplate.CASEWORK_SUBMITTED, + template_data={ + "blah": 123, + "foo": "ich habe hunger", + }, + ) + self.assertEqual(result, "USER_INTERACTION_CASEWORK_SUBMITTED_success") + + result = self.notification_service._send_notification( + sender=self.sender, + recipient=self.recipient, + verb=verb, + target_url=target_url, + notification_category=NotificationCategory.USER_INTERACTION, + notification_trigger=NotificationTrigger.CASEWORK_SUBMITTED, + email_template=EmailTemplate.CASEWORK_SUBMITTED, + template_data={ + "blah": 123, + "foo": "ich habe hunger", + }, + ) + self.assertEqual(result, "USER_INTERACTION_CASEWORK_SUBMITTED_duplicate") + + 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( + str(NotificationCategory.USER_INTERACTION), + notification.notification_category, + ) + self.assertEqual( + str(NotificationTrigger.CASEWORK_SUBMITTED), + notification.notification_trigger, + ) + self.assertFalse(notification.emailed) + + # when the email was not sent, yet it will still send it afterwards... + self.recipient.additional_json_data[ + "email_notification_categories" + ] = json.dumps(["USER_INTERACTION"]) + self.recipient.save() + + result = self.notification_service._send_notification( + sender=self.sender, + recipient=self.recipient, + verb=verb, + target_url=target_url, + notification_category=NotificationCategory.USER_INTERACTION, + notification_trigger=NotificationTrigger.CASEWORK_SUBMITTED, + email_template=EmailTemplate.CASEWORK_SUBMITTED, + template_data={ + "blah": 123, + "foo": "ich habe hunger", + }, + ) + self.assertEqual( + result, "USER_INTERACTION_CASEWORK_SUBMITTED_emailed_duplicate" + ) + + self.assertEqual(1, Notification.objects.count()) + notification: Notification = Notification.objects.first() + 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.notification_service._send_notification( + sender=self.sender, + recipient=self.recipient, + verb="should not be sent", + target_url="", + notification_category=NotificationCategory.USER_INTERACTION, + notification_trigger=NotificationTrigger.CASEWORK_SUBMITTED, + email_template=EmailTemplate.CASEWORK_SUBMITTED, + 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_categories" + ] = json.dumps(["USER_INTERACTION"]) + self.recipient.save() + self.notification_service._send_notification( + sender=self.sender, + recipient=self.recipient, + verb="should be sent", + target_url="", + notification_category=NotificationCategory.USER_INTERACTION, + notification_trigger=NotificationTrigger.CASEWORK_SUBMITTED, + email_template=EmailTemplate.CASEWORK_SUBMITTED, + template_data={}, + ) + self.assertEqual(2, Notification.objects.count()) + self.assertTrue(self._has_sent_emails()) diff --git a/server/vbv_lernwelt/notify/views.py b/server/vbv_lernwelt/notify/views.py index b5e2f50b..d4ee3ee9 100644 --- a/server/vbv_lernwelt/notify/views.py +++ b/server/vbv_lernwelt/notify/views.py @@ -4,11 +4,11 @@ from rest_framework.response import Response @api_view(["POST", "GET"]) def email_notification_settings(request): - EMAIL_NOTIFICATION_TYPES = "email_notification_types" + EMAIL_NOTIFICATION_CATEGORIES = "email_notification_categories" if request.method == "POST": - request.user.additional_json_data[EMAIL_NOTIFICATION_TYPES] = request.data + request.user.additional_json_data[EMAIL_NOTIFICATION_CATEGORIES] = request.data request.user.save() return Response( status=200, - data=request.user.additional_json_data.get(EMAIL_NOTIFICATION_TYPES, []), + data=request.user.additional_json_data.get(EMAIL_NOTIFICATION_CATEGORIES, []), ) 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" }