Merged in feature/VBV-475-email-notification-rebase (pull request #199)

Feature/VBV-475 email notification rebase
This commit is contained in:
Daniel Egger 2023-08-30 17:01:49 +00:00
commit 5cb60bbbcf
44 changed files with 1383 additions and 292 deletions

View File

@ -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`.

View File

@ -56,7 +56,7 @@ function onNotificationClick(notification: Notification) {
>
<div class="flex flex-row">
<img
v-if="notification.notification_type === 'USER_INTERACTION'"
v-if="notification.notification_category === 'USER_INTERACTION'"
alt="Notification icon"
class="mr-2 h-[45px] min-w-[45px] rounded-full"
:src="notification.actor_avatar_url ?? undefined"

View File

@ -491,7 +491,7 @@ export interface DocumentUploadData {
// notifications
export type NotificationType = "USER_INTERACTION" | "PROGRESS" | "INFORMATION";
export type NotificationCategory = "USER_INTERACTION" | "PROGRESS" | "INFORMATION";
export interface Notification {
// given by AbstractNotification model
@ -503,7 +503,7 @@ export interface Notification {
target: string | null;
action_object: string | null;
// given by Notification model
notification_type: NotificationType;
notification_category: NotificationCategory;
target_url: string | null;
actor_avatar_url: string | null;
course: string | null;

View File

@ -18,15 +18,31 @@ COPY ./server ${APP_HOME}
# define an alias for the specfic python version used in this file.
FROM python:${PYTHON_VERSION} as python
# Setup Supersonic (Cron scheduler for containers)
# https://github.com/aptible/supercronic
ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.26/supercronic-linux-amd64 \
SUPERCRONIC=supercronic-linux-amd64 \
SUPERCRONIC_SHA1SUM=7a79496cf8ad899b99a719355d4db27422396735
RUN apt-get update && apt-get install --no-install-recommends -y curl
RUN curl -fsSLO "$SUPERCRONIC_URL" \
&& echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \
&& chmod +x "$SUPERCRONIC" \
&& mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \
&& ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic
COPY ./compose/django/supercronic_crontab /app/supercronic_crontab
# Python build stage
FROM python as python-build-stage
ARG BUILD_ENVIRONMENT=production
# Install apt packages
RUN apt-get update && apt-get install --no-install-recommends -y \
RUN apt-get install --no-install-recommends -y \
# dependencies for building Python packages
build-essential \
# supervisor \
# psycopg2 dependencies
libpq-dev \
git
@ -56,7 +72,7 @@ RUN addgroup --system django \
# Install required system dependencies
RUN apt-get update && apt-get install --no-install-recommends -y \
RUN apt-get install --no-install-recommends -y \
# psycopg2 dependencies
libpq-dev \
# Translations dependencies
@ -104,6 +120,10 @@ RUN chown django:django ${APP_HOME}
USER django
# pip install under the user django, the scripts will be in /home/django/.local/bin
RUN pip install supervisor
COPY ./compose/django/supervisord.conf /app/supervisord.conf
EXPOSE 7555
ENTRYPOINT ["/entrypoint"]

View File

@ -15,4 +15,10 @@ else
python /app/manage.py migrate
fi
newrelic-admin run-program gunicorn config.asgi --bind 0.0.0.0:7555 --chdir=/app -k uvicorn.workers.UvicornWorker
# Use sentry for supercronic only in prod* environments
if [[ $IT_APP_ENVIRONMENT == prod* ]]; then
sed -i 's|command=/usr/local/bin/supercronic /app/supercronic_crontab|command=/usr/local/bin/supercronic /app/supercronic_crontab -sentry-dsn "$IT_SENTRY_DSN"|' /app/supervisord.conf
fi
# Set the command to run supervisord
/home/django/.local/bin/supervisord -c /app/supervisord.conf

View File

@ -0,0 +1,8 @@
# Run every minute
*/1 * * * * echo "hello"
# Run every 6 hours
0 */6 * * * /usr/local/bin/python /app/manage.py simple_dummy_job
# every day at 19:30
30 19 * * * /usr/local/bin/python /app/manage.py send_attendance_course_reminders

View File

@ -0,0 +1,14 @@
[supervisord]
nodaemon=true
[program:supercronic]
command=/usr/local/bin/supercronic /app/supercronic_crontab
redirect_stderr=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
[program:gunicorn]
command=newrelic-admin run-program gunicorn config.asgi --bind 0.0.0.0:7555 --chdir=/app -k uvicorn.workers.UvicornWorker
redirect_stderr=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0

Binary file not shown.

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
import os
import sys
import django
sys.path.append("../server")
os.environ.setdefault("IT_APP_ENVIRONMENT", "local")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.base")
django.setup()
from vbv_lernwelt.notify.email.email_services import (
EmailTemplate,
send_email,
create_template_data_from_course_session_attendance_course,
)
from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse
def main():
print("start")
if __name__ == "__main__":
main()
csac = CourseSessionAttendanceCourse.objects.get(pk=1)
print(csac)
print(csac.trainer)
print(csac.due_date)
result = send_email(
recipient_email="daniel.egger+sendgrid@gmail.com",
template=EmailTemplate.ATTENDANCE_COURSE_REMINDER,
template_data=create_template_data_from_course_session_attendance_course(csac),
template_language="de",
fail_silently=False,
)
print(result)

View File

@ -111,6 +111,7 @@ THIRD_PARTY_APPS = [
"graphene_django",
"notifications",
"django_jsonform",
"constance",
]
LOCAL_APPS = [
@ -636,7 +637,7 @@ EDONIQ_CERTIFICATE = env("IT_EDONIQ_CERTIFICATE", default="")
# Notifications
# django-notifications
DJANGO_NOTIFICATIONS_CONFIG = {"SOFT_DELETE": True}
DJANGO_NOTIFICATIONS_CONFIG = {"SOFT_DELETE": True, "USE_JSONFIELD": True}
NOTIFICATIONS_NOTIFICATION_MODEL = "notify.Notification"
# sendgrid (email notifications)
SENDGRID_API_KEY = env("IT_SENDGRID_API_KEY", default="")
@ -687,6 +688,15 @@ WHITENOISE_SKIP_COMPRESS_EXTENSIONS = (
)
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend"
CONSTANCE_CONFIG = {
"EMAIL_RECIPIENT_WHITELIST": (
"daniel.egger+sendgrid@gmail.com, elia.bieri@iterativ.ch",
"Which emails will recieve emails. Use single `*` for all OR comma separated list of emails. "
"Default value is empty and will not send any emails. (No regex support!)",
),
}
if APP_ENVIRONMENT == "local":
# http://whitenoise.evans.io/en/latest/django.html#using-whitenoise-in-development
INSTALLED_APPS = ["whitenoise.runserver_nostatic"] + INSTALLED_APPS # noqa F405

View File

@ -30,6 +30,7 @@ django-debug-toolbar # https://github.com/jazzband/django-debug-toolbar
django-extensions # https://github.com/django-extensions/django-extensions
django-coverage-plugin # https://github.com/nedbat/django_coverage_plugin
pytest-django # https://github.com/pytest-dev/pytest-django
freezegun # https://github.com/spulec/freezegun
# django-watchfiles custom PR
https://github.com/q0w/django-watchfiles/archive/issue-1.zip

View File

@ -122,6 +122,7 @@ django==3.2.20
# django-modelcluster
# django-notifications-hq
# django-permissionedforms
# django-picklefield
# django-redis
# django-storages
# django-stubs
@ -137,6 +138,8 @@ django==3.2.20
# wagtail-localize
django-click==2.3.0
# via -r requirements.in
django-constance==3.1.0
# via -r requirements.in
django-cors-headers==4.2.0
# via -r requirements.in
django-coverage-plugin==3.1.0
@ -163,6 +166,8 @@ django-notifications-hq==1.8.2
# via -r requirements.in
django-permissionedforms==0.1
# via wagtail
django-picklefield==3.1
# via django-constance
django-ratelimit==4.1.0
# via -r requirements.in
django-redis==5.3.0
@ -218,6 +223,8 @@ flake8==6.1.0
# flake8-isort
flake8-isort==6.0.0
# via -r requirements-dev.in
freezegun==1.2.2
# via -r requirements-dev.in
gitdb==4.0.10
# via gitdb2
gitdb2==4.0.2
@ -409,6 +416,7 @@ python-dateutil==2.8.2
# -r requirements.in
# botocore
# faker
# freezegun
python-dotenv==1.0.0
# via
# environs

View File

@ -28,6 +28,7 @@ django-storages
django-storages[azure]
django-notifications-hq
django-jsonform
django-constance
psycopg2-binary
gunicorn

View File

@ -84,6 +84,7 @@ django==3.2.20
# django-modelcluster
# django-notifications-hq
# django-permissionedforms
# django-picklefield
# django-redis
# django-storages
# django-taggit
@ -96,6 +97,8 @@ django==3.2.20
# wagtail-localize
django-click==2.3.0
# via -r requirements.in
django-constance==3.1.0
# via -r requirements.in
django-cors-headers==4.2.0
# via -r requirements.in
django-csp==3.7
@ -116,6 +119,8 @@ django-notifications-hq==1.8.2
# via -r requirements.in
django-permissionedforms==0.1
# via wagtail
django-picklefield==3.1
# via django-constance
django-ratelimit==4.1.0
# via -r requirements.in
django-redis==5.3.0

View File

@ -16,7 +16,8 @@ from vbv_lernwelt.core.models import User
from vbv_lernwelt.core.utils import find_first
from vbv_lernwelt.course.models import CourseCompletionStatus, CourseSession
from vbv_lernwelt.course.services import mark_course_completion
from vbv_lernwelt.notify.service import NotificationService
from vbv_lernwelt.notify.email.email_services import EmailTemplate
from vbv_lernwelt.notify.services import NotificationService
def update_assignment_completion(
@ -132,35 +133,20 @@ def update_assignment_completion(
if completion_status == AssignmentCompletionStatus.SUBMITTED:
ac.submitted_at = timezone.now()
if evaluation_user:
verb = gettext(
"%(sender)s hat die geleitete Fallarbeit «%(assignment_title)s» abgegeben."
) % {
"sender": assignment_user.get_full_name(),
"assignment_title": assignment.title,
}
NotificationService.send_user_interaction_notification(
NotificationService.send_assignment_submitted_notification(
recipient=evaluation_user,
verb=verb,
sender=ac.assignment_user,
course=course_session.course.title,
target_url=ac.get_assignment_evaluation_frontend_url(),
assignment_completion=ac,
)
elif completion_status == AssignmentCompletionStatus.EVALUATION_SUBMITTED:
ac.evaluation_submitted_at = timezone.now()
learning_content_assignment = assignment.learningcontentassignment_set.first()
if learning_content_assignment:
assignment_frontend_url = learning_content_assignment.get_frontend_url()
verb = gettext(
"%(sender)s hat die geleitete Fallarbeit «%(assignment_title)s» bewertet."
) % {
"sender": evaluation_user.get_full_name(),
"assignment_title": assignment.title,
}
NotificationService.send_user_interaction_notification(
NotificationService.send_assignment_evaluated_notification(
recipient=ac.assignment_user,
verb=verb,
sender=evaluation_user,
course=course_session.course.title,
assignment_completion=ac,
target_url=assignment_frontend_url,
)

View File

@ -14,6 +14,7 @@ from vbv_lernwelt.core.models import User
from vbv_lernwelt.core.utils import find_first
from vbv_lernwelt.course.creators.test_course import create_test_course
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.notify.models import Notification
class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
@ -153,6 +154,41 @@ class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
},
)
# check notification
self.assertEqual(Notification.objects.count(), 1)
notification = Notification.objects.first()
self.assertEquals(
"Test Student1 hat die geleitete Fallarbeit «Überprüfen einer Motorfahrzeugs-Versicherungspolice» abgegeben.",
notification.verb,
)
self.assertEquals(
"test-trainer1@example.com",
notification.recipient.email,
)
self.assertEquals(
"test-student1@example.com",
notification.actor.email,
)
self.assertEquals(
"USER_INTERACTION",
notification.notification_category,
)
self.assertEquals(
"CASEWORK_SUBMITTED",
notification.notification_trigger,
)
self.assertEquals(
notification.action_object,
db_entry,
)
self.assertEquals(
notification.course_session,
self.course_session,
)
self.assertTrue(
f"/course/test-lehrgang/cockpit/assignment" in notification.target_url
)
# second submit will fail
completion_data_string = json.dumps(
{
@ -346,6 +382,42 @@ class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
},
)
# check notification
self.assertEqual(Notification.objects.count(), 1)
notification = Notification.objects.first()
self.assertEquals(
"Test Trainer1 hat die geleitete Fallarbeit «Überprüfen einer Motorfahrzeugs-Versicherungspolice» bewertet.",
notification.verb,
)
self.assertEquals(
"test-student1@example.com",
notification.recipient.email,
)
self.assertEquals(
"test-trainer1@example.com",
notification.actor.email,
)
self.assertEquals(
"USER_INTERACTION",
notification.notification_category,
)
self.assertEquals(
"CASEWORK_EVALUATED",
notification.notification_trigger,
)
self.assertEquals(
notification.action_object,
db_entry,
)
self.assertEquals(
notification.course_session,
self.course_session,
)
self.assertEquals(
notification.target_url,
"/course/test-lehrgang/learn/fahrzeug/überprüfen-einer-motorfahrzeug-versicherungspolice",
)
# `EVALUATION_SUBMITTED` will create a new AssignmentCompletionAuditLog
acl = AssignmentCompletionAuditLog.objects.get(
assignment_user=self.student,

View File

@ -2,9 +2,29 @@ from django.contrib import admin
from django.contrib.auth import admin as auth_admin, get_user_model
from django.utils.translation import gettext_lazy as _
from vbv_lernwelt.core.models import JobLog
from vbv_lernwelt.core.utils import pretty_print_json
User = get_user_model()
class LogAdmin(admin.ModelAdmin):
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
def get_readonly_fields(self, request, obj=None):
# set all fields read only
return [field.name for field in self.opts.local_fields] + [
field.name for field in self.opts.local_many_to_many
]
def pretty_print_json(self, json_string):
return pretty_print_json(json_string)
@admin.register(User)
class UserAdmin(auth_admin.UserAdmin):
fieldsets = (
@ -34,3 +54,27 @@ class UserAdmin(auth_admin.UserAdmin):
"sso_id",
]
search_fields = ["first_name", "last_name", "email", "username", "sso_id"]
@admin.register(JobLog)
class JobLogAdmin(LogAdmin):
date_hierarchy = "started"
list_display = (
"job_name",
"started",
"ended",
"runtime",
"success",
)
list_filter = ["job_name", "success"]
def json_data_pretty(self, instance):
return self.pretty_print_json(instance.json_data)
def get_readonly_fields(self, request, obj=None):
return super().get_readonly_fields(request, obj) + ["json_data_pretty"]
def runtime(self, obj):
if obj.ended:
return (obj.ended - obj.started).seconds // 60
return None

View File

@ -0,0 +1,38 @@
import sys
import traceback
import structlog
from django.core.management.base import BaseCommand
from django.utils import timezone
from vbv_lernwelt.core.models import JobLog
logger = structlog.get_logger(__name__)
# pylint: disable=abstract-method
class LoggedCommand(BaseCommand):
def execute(self, *args, **options):
name = getattr(self, "name", "")
if not name:
name = sys.argv[1]
logger.info("start LoggedCommand", job_name=name, label="job_log")
self.job_log = JobLog.objects.create(job_name=name)
try:
super(LoggedCommand, self).execute(*args, **options)
self.job_log.refresh_from_db()
self.job_log.ended = timezone.now()
self.job_log.success = True
self.job_log.save()
logger.info(
"LoggedCommand successfully ended", job_name=name, label="job_log"
)
except Exception as e:
self.job_log.refresh_from_db()
self.job_log.error_message = str(e)
self.job_log.stack_trace = traceback.format_exc()
self.job_log.save()
logger.error("LoggedCommand failed", job_name=name, label="job_log")
raise e

View File

@ -0,0 +1,15 @@
import structlog
from vbv_lernwelt.core.base import LoggedCommand
logger = structlog.get_logger(__name__)
class Command(LoggedCommand):
help = "Simple Dummy Job to test if supercronic is working"
def handle(self, *args, **options):
logger.debug(
"Dummy Job finished",
label="job_lob",
)

View File

@ -0,0 +1,33 @@
# Generated by Django 3.2.20 on 2023-08-25 15:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="JobLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("started", models.DateTimeField(auto_now_add=True)),
("ended", models.DateTimeField(blank=True, null=True)),
("job_name", models.CharField(max_length=255)),
("success", models.BooleanField(default=False)),
("error_message", models.TextField(blank=True, default="")),
("stack_trace", models.TextField(blank=True, default="")),
("json_data", models.JSONField(default=dict)),
],
),
]

View File

@ -41,3 +41,16 @@ class SecurityRequestResponseLog(models.Model):
response_status_code = models.CharField(max_length=255, blank=True, default="")
additional_json_data = JSONField(default=dict, blank=True)
class JobLog(models.Model):
started = models.DateTimeField(auto_now_add=True)
ended = models.DateTimeField(blank=True, null=True)
job_name = models.CharField(max_length=255)
success = models.BooleanField(default=False)
error_message = models.TextField(blank=True, default="")
stack_trace = models.TextField(blank=True, default="")
json_data = models.JSONField(default=dict)
def __str__(self):
return "{job_name} {started:%H:%M %d.%m.%Y}".format(**self.__dict__)

View File

@ -1,6 +1,7 @@
import json
import re
from django.utils.safestring import mark_safe
from rest_framework.throttling import UserRateThrottle
@ -38,3 +39,16 @@ def replace_whitespace(text, replacement=" "):
def get_django_content_type(obj):
return obj._meta.app_label + "." + type(obj).__name__
def pretty_print_json(json_string):
try:
parsed = json_string
if isinstance(json_string, str):
parsed = json.loads(json_string)
return mark_safe(
"<pre>{}</pre>".format(json.dumps(parsed, indent=4, sort_keys=True))
)
# pylint: disable=broad-except
except Exception:
return json_string

View File

@ -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()

View File

@ -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"]},
),
]

View File

@ -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,18 +48,30 @@ class FeedbackResponse(models.Model):
HUNDRED = 100, "100%"
def save(self, *args, **kwargs):
if self._state.adding:
# 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
role="EXPERT",
course_session=self.course_session,
expert=self.circle,
)
for csu in course_session_users:
NotificationService.send_information_notification(
NotificationService.send_new_feedback_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}/",
feedback_response=self,
)
except Exception:
logger.exception(
"Failed to send feedback notification",
exc_info=True,
label="feedback_notification",
)
super(FeedbackResponse, self).save(*args, **kwargs)
data = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)

View File

@ -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")

View File

@ -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)],
}
)

View File

@ -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):

View File

@ -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")

View File

@ -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)

View File

@ -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],
),
)

View File

@ -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),
}

View File

@ -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,
)

View File

@ -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),
),
]

View File

@ -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),
]

View File

@ -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

View File

@ -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}: <a href='{target_url}'>Link</a>",
)
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

View File

@ -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()

View File

@ -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,
)

View File

@ -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"],
)

View File

@ -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())

View File

@ -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, []),
)

View File

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