Merged in feature/VBV-475-email-notification-rebase (pull request #199)
Feature/VBV-475 email notification rebase
This commit is contained in:
commit
5cb60bbbcf
|
|
@ -121,12 +121,12 @@ There are multiple ways on how to add new translations to Locize:
|
||||||
### Process one: Let Locize add missing keys automatically
|
### Process one: Let Locize add missing keys automatically
|
||||||
|
|
||||||
When running the app, it will automatically add the missing translation
|
When running the app, it will automatically add the missing translation
|
||||||
keys to Locize.
|
keys to Locize.
|
||||||
There you can translate them, and also add the German translation.
|
There you can translate them, and also add the German translation.
|
||||||
|
|
||||||
### Process two: Add keys manually
|
### Process two: Add keys manually
|
||||||
|
|
||||||
You can add the new keys manually to the German locale file in
|
You can add the new keys manually to the German locale file in
|
||||||
./client/locales/de/translation.json
|
./client/locales/de/translation.json
|
||||||
|
|
||||||
Then you can run the following command to add the keys to Locize:
|
Then you can run the following command to add the keys to Locize:
|
||||||
|
|
@ -149,7 +149,6 @@ The command will add the keys and the German translation to Locize.
|
||||||
Bonus: Use the "i18n ally" plugin in VSCode or IntelliJ to get extract untranslated
|
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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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__)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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,19 +48,31 @@ 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...
|
||||||
# with `id=UUIDField` it is always set...
|
create_new = self._state.adding
|
||||||
course_session_users = CourseSessionUser.objects.filter(
|
|
||||||
role="EXPERT", course_session=self.course_session, expert=self.circle
|
|
||||||
)
|
|
||||||
for csu in course_session_users:
|
|
||||||
NotificationService.send_information_notification(
|
|
||||||
recipient=csu.user,
|
|
||||||
verb=f"{_('New feedback for circle')} {self.circle.title}",
|
|
||||||
target_url=f"/course/{self.course_session.course.slug}/cockpit/feedback/{self.circle_id}/",
|
|
||||||
)
|
|
||||||
super(FeedbackResponse, self).save(*args, **kwargs)
|
super(FeedbackResponse, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if create_new:
|
||||||
|
# with `id=UUIDField` it is always set...
|
||||||
|
course_session_users = CourseSessionUser.objects.filter(
|
||||||
|
role="EXPERT",
|
||||||
|
course_session=self.course_session,
|
||||||
|
expert=self.circle,
|
||||||
|
)
|
||||||
|
for csu in course_session_users:
|
||||||
|
NotificationService.send_new_feedback_notification(
|
||||||
|
recipient=csu.user,
|
||||||
|
feedback_response=self,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to send feedback notification",
|
||||||
|
exc_info=True,
|
||||||
|
label="feedback_notification",
|
||||||
|
)
|
||||||
|
|
||||||
data = models.JSONField(default=dict)
|
data = models.JSONField(default=dict)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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),
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
)
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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, []),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue