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 Bonus: Use the "i18n ally" plugin in VSCode or IntelliJ to get extract untranslated
texts directly from the code to the translation.json file. texts directly from the code to the translation.json file.
### "_many" plural form in French and Italian ### "_many" plural form in French and Italian
See https://github.com/i18next/i18next/issues/1691#issuecomment-968063348 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. 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, For the `ObjectTypes` on the server, please use the postfix `ObjectType` for types,
like `LearningContentAttendanceCourseObjectType`. like `LearningContentAttendanceCourseObjectType`.

View File

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

View File

@ -491,7 +491,7 @@ export interface DocumentUploadData {
// notifications // notifications
export type NotificationType = "USER_INTERACTION" | "PROGRESS" | "INFORMATION"; export type NotificationCategory = "USER_INTERACTION" | "PROGRESS" | "INFORMATION";
export interface Notification { export interface Notification {
// given by AbstractNotification model // given by AbstractNotification model
@ -503,7 +503,7 @@ export interface Notification {
target: string | null; target: string | null;
action_object: string | null; action_object: string | null;
// given by Notification model // given by Notification model
notification_type: NotificationType; notification_category: NotificationCategory;
target_url: string | null; target_url: string | null;
actor_avatar_url: string | null; actor_avatar_url: string | null;
course: 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. # define an alias for the specfic python version used in this file.
FROM python:${PYTHON_VERSION} as python 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 # Python build stage
FROM python as python-build-stage FROM python as python-build-stage
ARG BUILD_ENVIRONMENT=production ARG BUILD_ENVIRONMENT=production
# Install apt packages # 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 # dependencies for building Python packages
build-essential \ build-essential \
# supervisor \
# psycopg2 dependencies # psycopg2 dependencies
libpq-dev \ libpq-dev \
git git
@ -56,7 +72,7 @@ RUN addgroup --system django \
# Install required system dependencies # 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 # psycopg2 dependencies
libpq-dev \ libpq-dev \
# Translations dependencies # Translations dependencies
@ -104,6 +120,10 @@ RUN chown django:django ${APP_HOME}
USER django 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 EXPOSE 7555
ENTRYPOINT ["/entrypoint"] ENTRYPOINT ["/entrypoint"]

View File

@ -15,4 +15,10 @@ else
python /app/manage.py migrate python /app/manage.py migrate
fi 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", "graphene_django",
"notifications", "notifications",
"django_jsonform", "django_jsonform",
"constance",
] ]
LOCAL_APPS = [ LOCAL_APPS = [
@ -636,7 +637,7 @@ EDONIQ_CERTIFICATE = env("IT_EDONIQ_CERTIFICATE", default="")
# Notifications # Notifications
# django-notifications # django-notifications
DJANGO_NOTIFICATIONS_CONFIG = {"SOFT_DELETE": True} DJANGO_NOTIFICATIONS_CONFIG = {"SOFT_DELETE": True, "USE_JSONFIELD": True}
NOTIFICATIONS_NOTIFICATION_MODEL = "notify.Notification" NOTIFICATIONS_NOTIFICATION_MODEL = "notify.Notification"
# sendgrid (email notifications) # sendgrid (email notifications)
SENDGRID_API_KEY = env("IT_SENDGRID_API_KEY", default="") SENDGRID_API_KEY = env("IT_SENDGRID_API_KEY", default="")
@ -687,6 +688,15 @@ WHITENOISE_SKIP_COMPRESS_EXTENSIONS = (
) )
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" 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": if APP_ENVIRONMENT == "local":
# http://whitenoise.evans.io/en/latest/django.html#using-whitenoise-in-development # http://whitenoise.evans.io/en/latest/django.html#using-whitenoise-in-development
INSTALLED_APPS = ["whitenoise.runserver_nostatic"] + INSTALLED_APPS # noqa F405 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-extensions # https://github.com/django-extensions/django-extensions
django-coverage-plugin # https://github.com/nedbat/django_coverage_plugin django-coverage-plugin # https://github.com/nedbat/django_coverage_plugin
pytest-django # https://github.com/pytest-dev/pytest-django pytest-django # https://github.com/pytest-dev/pytest-django
freezegun # https://github.com/spulec/freezegun
# django-watchfiles custom PR # django-watchfiles custom PR
https://github.com/q0w/django-watchfiles/archive/issue-1.zip https://github.com/q0w/django-watchfiles/archive/issue-1.zip

View File

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

View File

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

View File

@ -84,6 +84,7 @@ django==3.2.20
# django-modelcluster # django-modelcluster
# django-notifications-hq # django-notifications-hq
# django-permissionedforms # django-permissionedforms
# django-picklefield
# django-redis # django-redis
# django-storages # django-storages
# django-taggit # django-taggit
@ -96,6 +97,8 @@ django==3.2.20
# wagtail-localize # wagtail-localize
django-click==2.3.0 django-click==2.3.0
# via -r requirements.in # via -r requirements.in
django-constance==3.1.0
# via -r requirements.in
django-cors-headers==4.2.0 django-cors-headers==4.2.0
# via -r requirements.in # via -r requirements.in
django-csp==3.7 django-csp==3.7
@ -116,6 +119,8 @@ django-notifications-hq==1.8.2
# via -r requirements.in # via -r requirements.in
django-permissionedforms==0.1 django-permissionedforms==0.1
# via wagtail # via wagtail
django-picklefield==3.1
# via django-constance
django-ratelimit==4.1.0 django-ratelimit==4.1.0
# via -r requirements.in # via -r requirements.in
django-redis==5.3.0 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.core.utils import find_first
from vbv_lernwelt.course.models import CourseCompletionStatus, CourseSession from vbv_lernwelt.course.models import CourseCompletionStatus, CourseSession
from vbv_lernwelt.course.services import mark_course_completion 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( def update_assignment_completion(
@ -132,35 +133,20 @@ def update_assignment_completion(
if completion_status == AssignmentCompletionStatus.SUBMITTED: if completion_status == AssignmentCompletionStatus.SUBMITTED:
ac.submitted_at = timezone.now() ac.submitted_at = timezone.now()
if evaluation_user: if evaluation_user:
verb = gettext( NotificationService.send_assignment_submitted_notification(
"%(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(
recipient=evaluation_user, recipient=evaluation_user,
verb=verb,
sender=ac.assignment_user, sender=ac.assignment_user,
course=course_session.course.title, assignment_completion=ac,
target_url=ac.get_assignment_evaluation_frontend_url(),
) )
elif completion_status == AssignmentCompletionStatus.EVALUATION_SUBMITTED: elif completion_status == AssignmentCompletionStatus.EVALUATION_SUBMITTED:
ac.evaluation_submitted_at = timezone.now() ac.evaluation_submitted_at = timezone.now()
learning_content_assignment = assignment.learningcontentassignment_set.first() learning_content_assignment = assignment.learningcontentassignment_set.first()
if learning_content_assignment: if learning_content_assignment:
assignment_frontend_url = learning_content_assignment.get_frontend_url() assignment_frontend_url = learning_content_assignment.get_frontend_url()
verb = gettext( NotificationService.send_assignment_evaluated_notification(
"%(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(
recipient=ac.assignment_user, recipient=ac.assignment_user,
verb=verb,
sender=evaluation_user, sender=evaluation_user,
course=course_session.course.title, assignment_completion=ac,
target_url=assignment_frontend_url, 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.core.utils import find_first
from vbv_lernwelt.course.creators.test_course import create_test_course from vbv_lernwelt.course.creators.test_course import create_test_course
from vbv_lernwelt.course.models import CourseSession from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.notify.models import Notification
class AttendanceCourseUserMutationTestCase(GraphQLTestCase): 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 # second submit will fail
completion_data_string = json.dumps( 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 # `EVALUATION_SUBMITTED` will create a new AssignmentCompletionAuditLog
acl = AssignmentCompletionAuditLog.objects.get( acl = AssignmentCompletionAuditLog.objects.get(
assignment_user=self.student, 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.contrib.auth import admin as auth_admin, get_user_model
from django.utils.translation import gettext_lazy as _ 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() 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) @admin.register(User)
class UserAdmin(auth_admin.UserAdmin): class UserAdmin(auth_admin.UserAdmin):
fieldsets = ( fieldsets = (
@ -34,3 +54,27 @@ class UserAdmin(auth_admin.UserAdmin):
"sso_id", "sso_id",
] ]
search_fields = ["first_name", "last_name", "email", "username", "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="") response_status_code = models.CharField(max_length=255, blank=True, default="")
additional_json_data = JSONField(default=dict, blank=True) 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 json
import re import re
from django.utils.safestring import mark_safe
from rest_framework.throttling import UserRateThrottle from rest_framework.throttling import UserRateThrottle
@ -38,3 +39,16 @@ def replace_whitespace(text, replacement=" "):
def get_django_content_type(obj): def get_django_content_type(obj):
return obj._meta.app_label + "." + type(obj).__name__ 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", location="Handelsschule KV Bern, Zimmer 123, Eigerstrasse 16, 3012 Bern",
trainer="Roland Grossenbacher, roland.grossenbacher@helvetia.ch", trainer="Roland Grossenbacher, roland.grossenbacher@helvetia.ch",
) )
tuesday_in_two_weeks = ( tuesday_in_one_week = (
datetime.now() + relativedelta(weekday=TU(2)) + relativedelta(weeks=2) datetime.now() + relativedelta(weekday=TU) + relativedelta(weeks=1)
) )
csac.due_date.start = timezone.make_aware( 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( 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() 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 uuid
import structlog
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from vbv_lernwelt.core.models import User from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSessionUser 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): class FeedbackIntegerField(models.IntegerField):
@ -45,18 +48,30 @@ class FeedbackResponse(models.Model):
HUNDRED = 100, "100%" HUNDRED = 100, "100%"
def save(self, *args, **kwargs): 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... # with `id=UUIDField` it is always set...
course_session_users = CourseSessionUser.objects.filter( 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: for csu in course_session_users:
NotificationService.send_information_notification( NotificationService.send_new_feedback_notification(
recipient=csu.user, recipient=csu.user,
verb=f"{_('New feedback for circle')} {self.circle.title}", feedback_response=self,
target_url=f"/course/{self.course_session.course.slug}/cockpit/feedback/{self.circle_id}/", )
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) data = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True) 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.factories import FeedbackResponseFactory
from vbv_lernwelt.feedback.models import FeedbackResponse from vbv_lernwelt.feedback.models import FeedbackResponse
from vbv_lernwelt.learnpath.models import Circle 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): class FeedbackApiBaseTestCase(APITestCase):
@ -62,20 +66,28 @@ class FeedbackSummaryApiTestCase(FeedbackApiBaseTestCase):
basis_circle = Circle.objects.get(slug="test-lehrgang-lp-circle-reisen") basis_circle = Circle.objects.get(slug="test-lehrgang-lp-circle-reisen")
csu.expert.add(basis_circle) csu.expert.add(basis_circle)
FeedbackResponse.objects.create( feedback = FeedbackResponse.objects.create(
circle=basis_circle, course_session=csu.course_session circle=basis_circle, course_session=csu.course_session
) )
notifications = Notification.objects.all() self.assertEqual(Notification.objects.count(), 1)
self.assertEqual(len(notifications), 1) notification = Notification.objects.first()
self.assertEqual(notifications[0].recipient, expert) self.assertEqual(notification.recipient, expert)
self.assertEqual( 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( self.assertEqual(
notifications[0].target_url, notification.target_url,
f"/course/{self.course_session.course.slug}/cockpit/feedback/{basis_circle.id}/", 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): def test_triggers_notification_only_on_create(self):
expert = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch") expert = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch")

View File

@ -3,7 +3,6 @@ from typing import Any, Dict, List
import structlog import structlog
from django.utils import timezone from django.utils import timezone
from openpyxl.reader.excel import load_workbook
from vbv_lernwelt.assignment.models import AssignmentType from vbv_lernwelt.assignment.models import AssignmentType
from vbv_lernwelt.core.models import User from vbv_lernwelt.core.models import User
@ -25,6 +24,7 @@ from vbv_lernwelt.learnpath.models import (
LearningContentAttendanceCourse, LearningContentAttendanceCourse,
LearningContentEdoniqTest, LearningContentEdoniqTest,
) )
from vbv_lernwelt.notify.models import NotificationCategory
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@ -248,7 +248,11 @@ def create_or_update_user(
if not user: if not user:
# create 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.email = email
user.sso_id = user.sso_id or sso_id user.sso_id = user.sso_id or sso_id
@ -270,6 +274,8 @@ def import_course_sessions_from_excel(
"Basis", "Basis",
"Fahrzeug", "Fahrzeug",
] ]
from openpyxl.reader.excel import load_workbook
workbook = load_workbook(filename=filename) workbook = load_workbook(filename=filename)
sheet = workbook["Schulungen Durchführung"] sheet = workbook["Schulungen Durchführung"]
no_course = course is None no_course = course is None
@ -516,6 +522,8 @@ def get_uk_course(language: str) -> Course:
def import_trainers_from_excel_for_training( def import_trainers_from_excel_for_training(
filename: str, language="de", course: Course = None filename: str, language="de", course: Course = None
): ):
from openpyxl.reader.excel import load_workbook
workbook = load_workbook(filename=filename) workbook = load_workbook(filename=filename)
sheet = workbook["Schulungen Trainer"] 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): def import_students_from_excel(filename: str):
from openpyxl.reader.excel import load_workbook
workbook = load_workbook(filename=filename) workbook = load_workbook(filename=filename)
sheet = workbook.active 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): def sync_students_from_t2l_excel(filename: str):
from openpyxl.reader.excel import load_workbook
workbook = load_workbook(filename=filename) workbook = load_workbook(filename=filename)
sheet = workbook.active sheet = workbook.active
@ -699,8 +711,12 @@ def sync_students_from_t2l(data):
def update_user_json_data(user: User, data: Dict[str, Any]): 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( 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", "Lehrvertragsnummer": "1234",
"Tel. Privat": "079 593 83 43", "Tel. Privat": "079 593 83 43",
"Geburtsdatum": "01.01.2000", "Geburtsdatum": "01.01.2000",
"email_notification_categories": ["INFORMATION"],
} }
def test_create_student(self): 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.apps import AppConfig
from django.contrib import admin
class NotifyConfig(AppConfig): class NotifyConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "vbv_lernwelt.notify" 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 django.utils import timezone
from vbv_lernwelt.core.admin import User 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 from vbv_lernwelt.notify.tests.factories import NotificationFactory
@ -28,8 +28,7 @@ def create_default_notifications() -> int:
verb="Alexandra hat einen neuen Beitrag erfasst", verb="Alexandra hat einen neuen Beitrag erfasst",
actor_avatar_url=avatar_urls[0], actor_avatar_url=avatar_urls[0],
target_url="/", target_url="/",
notification_type=NotificationType.USER_INTERACTION, notification_category=NotificationCategory.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[0], timestamp=timestamps[0],
), ),
NotificationFactory( NotificationFactory(
@ -38,8 +37,7 @@ def create_default_notifications() -> int:
verb="Alexandra hat einen neuen Beitrag erfasst", verb="Alexandra hat einen neuen Beitrag erfasst",
actor_avatar_url=avatar_urls[0], actor_avatar_url=avatar_urls[0],
target_url="/", target_url="/",
notification_type=NotificationType.USER_INTERACTION, notification_category=NotificationCategory.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[1], timestamp=timestamps[1],
), ),
NotificationFactory( NotificationFactory(
@ -48,8 +46,7 @@ def create_default_notifications() -> int:
verb="Alexandra hat einen neuen Beitrag erfasst", verb="Alexandra hat einen neuen Beitrag erfasst",
actor_avatar_url=avatar_urls[0], actor_avatar_url=avatar_urls[0],
target_url="/", target_url="/",
notification_type=NotificationType.USER_INTERACTION, notification_category=NotificationCategory.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[2], timestamp=timestamps[2],
), ),
NotificationFactory( NotificationFactory(
@ -58,8 +55,7 @@ def create_default_notifications() -> int:
verb="Alexandra hat einen neuen Beitrag erfasst", verb="Alexandra hat einen neuen Beitrag erfasst",
actor_avatar_url=avatar_urls[0], actor_avatar_url=avatar_urls[0],
target_url="/", target_url="/",
notification_type=NotificationType.USER_INTERACTION, notification_category=NotificationCategory.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[3], timestamp=timestamps[3],
), ),
NotificationFactory( NotificationFactory(
@ -68,8 +64,7 @@ def create_default_notifications() -> int:
verb="Bianca hat für den Auftrag Autoversicherung 3 eine Lösung abgegeben", verb="Bianca hat für den Auftrag Autoversicherung 3 eine Lösung abgegeben",
actor_avatar_url=avatar_urls[1], actor_avatar_url=avatar_urls[1],
target_url="/", target_url="/",
notification_type=NotificationType.USER_INTERACTION, notification_category=NotificationCategory.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[4], timestamp=timestamps[4],
), ),
NotificationFactory( NotificationFactory(
@ -78,8 +73,7 @@ def create_default_notifications() -> int:
verb="Bianca hat für den Auftrag Autoversicherung 1 eine Lösung abgegeben", verb="Bianca hat für den Auftrag Autoversicherung 1 eine Lösung abgegeben",
actor_avatar_url=avatar_urls[1], actor_avatar_url=avatar_urls[1],
target_url="/", target_url="/",
notification_type=NotificationType.USER_INTERACTION, notification_category=NotificationCategory.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[5], timestamp=timestamps[5],
), ),
NotificationFactory( NotificationFactory(
@ -88,8 +82,7 @@ def create_default_notifications() -> int:
verb="Bianca hat für den Auftrag Autoversicherung 2 eine Lösung abgegeben", verb="Bianca hat für den Auftrag Autoversicherung 2 eine Lösung abgegeben",
actor_avatar_url=avatar_urls[1], actor_avatar_url=avatar_urls[1],
target_url="/", target_url="/",
notification_type=NotificationType.USER_INTERACTION, notification_category=NotificationCategory.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[6], timestamp=timestamps[6],
), ),
NotificationFactory( NotificationFactory(
@ -98,8 +91,7 @@ def create_default_notifications() -> int:
verb="Bianca hat für den Auftrag Autoversicherung 4 eine Lösung abgegeben", verb="Bianca hat für den Auftrag Autoversicherung 4 eine Lösung abgegeben",
actor_avatar_url=avatar_urls[1], actor_avatar_url=avatar_urls[1],
target_url="/", target_url="/",
notification_type=NotificationType.USER_INTERACTION, notification_category=NotificationCategory.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[7], timestamp=timestamps[7],
), ),
NotificationFactory( NotificationFactory(
@ -108,8 +100,7 @@ def create_default_notifications() -> int:
verb="Chantal hat eine Bewertung für den Transferauftrag 3 eingegeben", verb="Chantal hat eine Bewertung für den Transferauftrag 3 eingegeben",
target_url="/", target_url="/",
actor_avatar_url=avatar_urls[2], actor_avatar_url=avatar_urls[2],
notification_type=NotificationType.USER_INTERACTION, notification_category=NotificationCategory.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[8], timestamp=timestamps[8],
), ),
NotificationFactory( NotificationFactory(
@ -118,8 +109,7 @@ def create_default_notifications() -> int:
verb="Chantal hat eine Bewertung für den Transferauftrag 4 eingegeben", verb="Chantal hat eine Bewertung für den Transferauftrag 4 eingegeben",
target_url="/", target_url="/",
actor_avatar_url=avatar_urls[2], actor_avatar_url=avatar_urls[2],
notification_type=NotificationType.USER_INTERACTION, notification_category=NotificationCategory.USER_INTERACTION,
course="Versicherungsvermittler/-in",
timestamp=timestamps[9], timestamp=timestamps[9],
), ),
NotificationFactory( NotificationFactory(
@ -127,22 +117,21 @@ def create_default_notifications() -> int:
actor=user, actor=user,
verb="Super, du kommst in deinem Lernpfad gut voran. Schaue dir jetzt die verfügbaren Prüfungstermine an.", verb="Super, du kommst in deinem Lernpfad gut voran. Schaue dir jetzt die verfügbaren Prüfungstermine an.",
target_url="/", target_url="/",
notification_type=NotificationType.PROGRESS, notification_category=NotificationCategory.PROGRESS,
course="Versicherungsvermittler/-in",
timestamp=timestamps[10], timestamp=timestamps[10],
), ),
NotificationFactory( NotificationFactory(
recipient=user, recipient=user,
actor=user, actor=user,
verb="Wartungsarbeiten: 20.12.2022 08:00 - 12:00", verb="Wartungsarbeiten: 20.12.2022 08:00 - 12:00",
notification_type=NotificationType.INFORMATION, notification_category=NotificationCategory.INFORMATION,
timestamp=timestamps[11], timestamp=timestamps[11],
), ),
NotificationFactory( NotificationFactory(
recipient=user, recipient=user,
actor=user, actor=user,
verb="Wartungsarbeiten: 31.01.2023 08:00 - 12:00", verb="Wartungsarbeiten: 31.01.2023 08:00 - 12:00",
notification_type=NotificationType.INFORMATION, notification_category=NotificationCategory.INFORMATION,
timestamp=timestamps[12], 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 django.utils.translation import gettext_lazy as _
from notifications.base.models import AbstractNotification 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") USER_INTERACTION = "USER_INTERACTION", _("User Interaction")
PROGRESS = "PROGRESS", _("Progress") PROGRESS = "PROGRESS", _("Progress")
INFORMATION = "INFORMATION", _("Information") 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): class Notification(AbstractNotification):
# UUIDs are not supported by the notifications app... # UUIDs are not supported by the notifications app...
# id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
notification_type = models.CharField( notification_category = models.CharField(
max_length=32, max_length=255,
choices=NotificationType.choices, choices=NotificationCategory.choices,
default=NotificationType.INFORMATION, 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): class Meta(AbstractNotification.Meta):
abstract = False 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.admin import User
from vbv_lernwelt.core.tests.factories import UserFactory 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 from vbv_lernwelt.notify.tests.factories import NotificationFactory
@ -115,7 +115,7 @@ class TestNotificationSettingsApi(APITestCase):
def test_store_retrieve_settings(self): def test_store_retrieve_settings(self):
notification_settings = json.dumps( notification_settings = json.dumps(
[NotificationType.INFORMATION, NotificationType.PROGRESS] [NotificationCategory.INFORMATION, NotificationCategory.PROGRESS]
) )
api_path = "/api/notify/email_notification_settings/" api_path = "/api/notify/email_notification_settings/"
@ -128,7 +128,7 @@ class TestNotificationSettingsApi(APITestCase):
self.assertEqual(response.json(), notification_settings) self.assertEqual(response.json(), notification_settings)
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertEqual( self.assertEqual(
self.user.additional_json_data["email_notification_types"], self.user.additional_json_data["email_notification_categories"],
notification_settings, 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 import json
from typing import List
from django.test import TestCase from django.test import TestCase
from vbv_lernwelt.core.models import User from vbv_lernwelt.core.models import User
from vbv_lernwelt.core.tests.factories import UserFactory from vbv_lernwelt.core.tests.factories import UserFactory
from vbv_lernwelt.notify.models import Notification, NotificationType from vbv_lernwelt.notify.email.email_services import EmailTemplate
from vbv_lernwelt.notify.service import NotificationService from vbv_lernwelt.notify.models import (
Notification,
NotificationCategory,
NotificationTrigger,
)
from vbv_lernwelt.notify.services import NotificationService
class TestNotificationService(TestCase): 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: def setUp(self) -> None:
self._emails_sent = 0
self.notification_service = NotificationService 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.admin = UserFactory(username="admin", email="admin")
self.sender_username = "Bob" 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.sender = User.objects.get(username=self.sender_username)
self.recipient_username = "Alice" 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 = User.objects.get(username=self.recipient_username)
self.recipient.additional_json_data["email_notification_types"] = json.dumps(
["USER_INTERACTION", "INFORMATION"]
)
self.recipient.save() self.recipient.save()
self.client.login(username=self.recipient, password="pw") self.client.login(username=self.recipient, password="pw")
def test_send_information_notification(self): def test_send_notification_without_email(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):
verb = "Anne hat deinen Auftrag bewertet" verb = "Anne hat deinen Auftrag bewertet"
target_url = "https://www.vbv.ch" target_url = "https://www.vbv.ch"
course = "Versicherungsvermittler/in" result = self.notification_service._send_notification(
self.notification_service.send_user_interaction_notification(
sender=self.sender, sender=self.sender,
recipient=self.recipient, recipient=self.recipient,
verb=verb, verb=verb,
target_url=target_url, 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(result, "USER_INTERACTION_CASEWORK_SUBMITTED_success")
self.assertEqual(1, len(notifications))
notification = notifications[0] self.assertEqual(1, Notification.objects.count())
notification: Notification = Notification.objects.first()
self.assertEqual(self.sender, notification.actor) self.assertEqual(self.sender, notification.actor)
self.assertEqual(verb, notification.verb) self.assertEqual(verb, notification.verb)
self.assertEqual(target_url, notification.target_url) self.assertEqual(target_url, notification.target_url)
self.assertEqual(course, notification.course)
self.assertEqual( 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) 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"]) @api_view(["POST", "GET"])
def email_notification_settings(request): def email_notification_settings(request):
EMAIL_NOTIFICATION_TYPES = "email_notification_types" EMAIL_NOTIFICATION_CATEGORIES = "email_notification_categories"
if request.method == "POST": 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() request.user.save()
return Response( return Response(
status=200, 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,.*", "img base64 content": "regex:data:image/png;base64,.*",
"sentry url": "https://2df6096a4fd94bd6b4802124d10e4b8d@o8544.ingest.sentry.io/4504157846372352", "sentry url": "https://2df6096a4fd94bd6b4802124d10e4b8d@o8544.ingest.sentry.io/4504157846372352",
"git commit": "bdadf52b849bb5fa47854a3094f4da6fe9d54d02", "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"
} }