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"
}