Squash merge of code from Elia
This commit is contained in:
parent
e96c21f623
commit
56e454cc8b
|
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,28 @@ 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/send_attendance_course_reminders /app/send_attendance_course_reminders
|
||||||
|
|
||||||
# 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 \
|
||||||
# psycopg2 dependencies
|
# psycopg2 dependencies
|
||||||
|
|
@ -56,7 +71,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
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,9 @@ else
|
||||||
python /app/manage.py migrate
|
python /app/manage.py migrate
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ $IT_APP_ENVIRONMENT != dev* ]]; then
|
||||||
|
# Start periodic tasks
|
||||||
|
/usr/local/bin/supercronic -sentry-dsn "$IT_SENTRY_DSN" -split-logs /app/send_attendance_course_reminders &
|
||||||
|
fi
|
||||||
|
|
||||||
newrelic-admin run-program gunicorn config.asgi --bind 0.0.0.0:7555 --chdir=/app -k uvicorn.workers.UvicornWorker
|
newrelic-admin run-program gunicorn config.asgi --bind 0.0.0.0:7555 --chdir=/app -k uvicorn.workers.UvicornWorker
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@daily /usr/local/bin/python /app/manage.py send_attendance_course_reminders
|
||||||
|
|
@ -636,7 +636,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="")
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,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.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.service import EmailTemplate, NotificationService
|
||||||
|
|
||||||
|
|
||||||
def update_assignment_completion(
|
def update_assignment_completion(
|
||||||
|
|
@ -144,6 +144,7 @@ def update_assignment_completion(
|
||||||
sender=ac.assignment_user,
|
sender=ac.assignment_user,
|
||||||
course=course_session.course.title,
|
course=course_session.course.title,
|
||||||
target_url=ac.get_assignment_evaluation_frontend_url(),
|
target_url=ac.get_assignment_evaluation_frontend_url(),
|
||||||
|
email_template=EmailTemplate.CASEWORK_SUBMITTED,
|
||||||
)
|
)
|
||||||
elif completion_status == AssignmentCompletionStatus.EVALUATION_SUBMITTED:
|
elif completion_status == AssignmentCompletionStatus.EVALUATION_SUBMITTED:
|
||||||
ac.evaluation_submitted_at = timezone.now()
|
ac.evaluation_submitted_at = timezone.now()
|
||||||
|
|
@ -162,6 +163,7 @@ def update_assignment_completion(
|
||||||
sender=evaluation_user,
|
sender=evaluation_user,
|
||||||
course=course_session.course.title,
|
course=course_session.course.title,
|
||||||
target_url=assignment_frontend_url,
|
target_url=assignment_frontend_url,
|
||||||
|
email_template=EmailTemplate.CASEWORK_EVALUATED,
|
||||||
)
|
)
|
||||||
|
|
||||||
ac.completion_status = completion_status.value
|
ac.completion_status = completion_status.value
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ 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.service import EmailTemplate, NotificationService
|
||||||
|
|
||||||
|
|
||||||
class FeedbackIntegerField(models.IntegerField):
|
class FeedbackIntegerField(models.IntegerField):
|
||||||
|
|
@ -55,6 +55,7 @@ class FeedbackResponse(models.Model):
|
||||||
recipient=csu.user,
|
recipient=csu.user,
|
||||||
verb=f"{_('New feedback for circle')} {self.circle.title}",
|
verb=f"{_('New feedback for circle')} {self.circle.title}",
|
||||||
target_url=f"/course/{self.course_session.course.slug}/cockpit/feedback/{self.circle_id}/",
|
target_url=f"/course/{self.course_session.course.slug}/cockpit/feedback/{self.circle_id}/",
|
||||||
|
email_template=EmailTemplate.NEW_FEEDBACK,
|
||||||
)
|
)
|
||||||
super(FeedbackResponse, self).save(*args, **kwargs)
|
super(FeedbackResponse, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from vbv_lernwelt.learnpath.models import (
|
||||||
LearningContentAttendanceCourse,
|
LearningContentAttendanceCourse,
|
||||||
LearningContentEdoniqTest,
|
LearningContentEdoniqTest,
|
||||||
)
|
)
|
||||||
|
from vbv_lernwelt.notify.models import NotificationType
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
@ -248,7 +249,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
|
||||||
|
|
@ -699,8 +704,9 @@ 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_types": [str(NotificationType.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_types": ["INFORMATION"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_create_student(self):
|
def test_create_student(self):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import djclick as click
|
||||||
|
import structlog
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.models import User
|
||||||
|
from vbv_lernwelt.course.models import CourseSessionUser
|
||||||
|
from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse
|
||||||
|
from vbv_lernwelt.notify.service import EmailTemplate, NotificationService
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
PRESENCE_COURSE_REMINDER_LEAD_TIME = timedelta(weeks=2)
|
||||||
|
DATETIME_FORMAT_STR = "%H:%M %d.%m.%Y"
|
||||||
|
|
||||||
|
|
||||||
|
def format_datetime(dt: timezone.datetime) -> str:
|
||||||
|
return dt.astimezone(timezone.get_current_timezone()).strftime(DATETIME_FORMAT_STR)
|
||||||
|
|
||||||
|
|
||||||
|
def send_notification(
|
||||||
|
recipient: User, attendance_course: CourseSessionAttendanceCourse
|
||||||
|
):
|
||||||
|
NotificationService.send_information_notification(
|
||||||
|
recipient=recipient,
|
||||||
|
verb=_("Erinnerung: Bald findet ein Präsenzkurs statt"),
|
||||||
|
target_url=attendance_course.learning_content.get_frontend_url(),
|
||||||
|
email_template=EmailTemplate.ATTENDANCE_COURSE_REMINDER,
|
||||||
|
template_data={
|
||||||
|
"attendance_course": attendance_course.learning_content.title,
|
||||||
|
"location": attendance_course.location,
|
||||||
|
"trainer": attendance_course.trainer,
|
||||||
|
"start": format_datetime(attendance_course.due_date.start),
|
||||||
|
"end": format_datetime(attendance_course.due_date.end),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_attendance_course():
|
||||||
|
"""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
|
||||||
|
logger.info(
|
||||||
|
"Querying for attendance courses in specified time range",
|
||||||
|
start_time=start.strftime(DATETIME_FORMAT_STR),
|
||||||
|
end_time=end.strftime(DATETIME_FORMAT_STR),
|
||||||
|
)
|
||||||
|
attendance_courses = CourseSessionAttendanceCourse.objects.filter(
|
||||||
|
due_date__start__lte=end,
|
||||||
|
due_date__start__gte=start,
|
||||||
|
)
|
||||||
|
for attendance_course in attendance_courses:
|
||||||
|
cs_id = attendance_course.course_session.id
|
||||||
|
csu = CourseSessionUser.objects.filter(course_session_id=cs_id)
|
||||||
|
for user in csu:
|
||||||
|
send_notification(user.user, attendance_course)
|
||||||
|
if not attendance_courses:
|
||||||
|
logger.info("No attendance courses found")
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
def command():
|
||||||
|
check_attendance_course()
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
@ -11,35 +12,60 @@ from vbv_lernwelt.notify.models import Notification, NotificationType
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
|
||||||
class EmailService:
|
class EmailService:
|
||||||
|
"""Email service class implemented using the Sendgrid API"""
|
||||||
|
|
||||||
_sendgrid_client = SendGridAPIClient(setting("SENDGRID_API_KEY"))
|
_sendgrid_client = SendGridAPIClient(setting("SENDGRID_API_KEY"))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def send_email(cls, recipient: User, verb: str, target_url) -> bool:
|
def send_email(
|
||||||
|
cls,
|
||||||
|
recipient: User,
|
||||||
|
template: EmailTemplate,
|
||||||
|
template_data: dict,
|
||||||
|
) -> None:
|
||||||
message = Mail(
|
message = Mail(
|
||||||
from_email="info@iterativ.ch",
|
from_email="noreply@my.vbv-afa.ch",
|
||||||
to_emails=recipient.email,
|
to_emails=recipient.email,
|
||||||
subject=f"myVBV - {verb}",
|
|
||||||
## TODO: Add HTML content.
|
|
||||||
html_content=f"{verb}: <a href='{target_url}'>Link</a>",
|
|
||||||
)
|
)
|
||||||
try:
|
message.template_id = template.value.get(
|
||||||
cls._sendgrid_client.send(message)
|
recipient.language, template.value["de"]
|
||||||
logger.info(f"Successfully sent email to {recipient}", label="email")
|
)
|
||||||
return True
|
message.dynamic_template_data = template_data
|
||||||
except Exception as e:
|
cls._sendgrid_client.send(message)
|
||||||
logger.error(
|
|
||||||
f"Failed to send email to {recipient}: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
label="email",
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationService:
|
class NotificationService:
|
||||||
@classmethod
|
@classmethod
|
||||||
def send_user_interaction_notification(
|
def send_user_interaction_notification(
|
||||||
cls, recipient: User, verb: str, sender: User, course: str, target_url: str
|
cls,
|
||||||
|
recipient: User,
|
||||||
|
verb: str,
|
||||||
|
sender: User,
|
||||||
|
course: str,
|
||||||
|
target_url: str,
|
||||||
|
email_template: EmailTemplate,
|
||||||
|
template_data: dict = {},
|
||||||
) -> None:
|
) -> None:
|
||||||
cls._send_notification(
|
cls._send_notification(
|
||||||
recipient=recipient,
|
recipient=recipient,
|
||||||
|
|
@ -48,11 +74,19 @@ class NotificationService:
|
||||||
course=course,
|
course=course,
|
||||||
target_url=target_url,
|
target_url=target_url,
|
||||||
notification_type=NotificationType.USER_INTERACTION,
|
notification_type=NotificationType.USER_INTERACTION,
|
||||||
|
email_template=email_template,
|
||||||
|
template_data=template_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def send_progress_notification(
|
def send_progress_notification(
|
||||||
cls, recipient: User, verb: str, course: str, target_url: str
|
cls,
|
||||||
|
recipient: User,
|
||||||
|
verb: str,
|
||||||
|
course: str,
|
||||||
|
target_url: str,
|
||||||
|
email_template: EmailTemplate,
|
||||||
|
template_data: dict = {},
|
||||||
) -> None:
|
) -> None:
|
||||||
cls._send_notification(
|
cls._send_notification(
|
||||||
recipient=recipient,
|
recipient=recipient,
|
||||||
|
|
@ -60,17 +94,26 @@ class NotificationService:
|
||||||
course=course,
|
course=course,
|
||||||
target_url=target_url,
|
target_url=target_url,
|
||||||
notification_type=NotificationType.PROGRESS,
|
notification_type=NotificationType.PROGRESS,
|
||||||
|
email_template=email_template,
|
||||||
|
template_data=template_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def send_information_notification(
|
def send_information_notification(
|
||||||
cls, recipient: User, verb: str, target_url: str
|
cls,
|
||||||
|
recipient: User,
|
||||||
|
verb: str,
|
||||||
|
target_url: str,
|
||||||
|
email_template: EmailTemplate,
|
||||||
|
template_data: dict = {},
|
||||||
) -> None:
|
) -> None:
|
||||||
cls._send_notification(
|
cls._send_notification(
|
||||||
recipient=recipient,
|
recipient=recipient,
|
||||||
verb=verb,
|
verb=verb,
|
||||||
target_url=target_url,
|
target_url=target_url,
|
||||||
notification_type=NotificationType.INFORMATION,
|
notification_type=NotificationType.INFORMATION,
|
||||||
|
email_template=email_template,
|
||||||
|
template_data=template_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -79,42 +122,66 @@ class NotificationService:
|
||||||
recipient: User,
|
recipient: User,
|
||||||
verb: str,
|
verb: str,
|
||||||
notification_type: NotificationType,
|
notification_type: NotificationType,
|
||||||
sender: Optional[User] = None,
|
email_template: EmailTemplate,
|
||||||
course: Optional[str] = None,
|
template_data: dict,
|
||||||
target_url: Optional[str] = None,
|
sender: User | None = None,
|
||||||
|
course: str | None = None,
|
||||||
|
target_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
actor_avatar_url: Optional[str] = None
|
actor_avatar_url: Optional[str] = None
|
||||||
if not sender:
|
if not sender:
|
||||||
sender = User.objects.get(email="admin")
|
sender = User.objects.get(email="admin")
|
||||||
else:
|
else:
|
||||||
actor_avatar_url = sender.avatar_url
|
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(
|
log = logger.bind(
|
||||||
|
recipient=recipient.get_full_name(),
|
||||||
|
sender=sender.get_full_name(),
|
||||||
verb=verb,
|
verb=verb,
|
||||||
notification_type=notification_type,
|
notification_type=notification_type,
|
||||||
sender=sender.get_full_name(),
|
|
||||||
recipient=recipient.get_full_name(),
|
|
||||||
course=course,
|
course=course,
|
||||||
target_url=target_url,
|
target_url=target_url,
|
||||||
|
template_data=template_data,
|
||||||
)
|
)
|
||||||
|
if NotificationService._is_duplicate_notification(
|
||||||
|
recipient=recipient,
|
||||||
|
verb=verb,
|
||||||
|
notification_type=notification_type,
|
||||||
|
target_url=target_url,
|
||||||
|
template_name=email_template.name,
|
||||||
|
template_data=template_data,
|
||||||
|
):
|
||||||
|
log.warn("A duplicate notification was omitted from being sent")
|
||||||
|
return
|
||||||
|
|
||||||
|
emailed = False
|
||||||
|
if cls._should_send_email(notification_type, recipient):
|
||||||
|
emailed = cls._send_email(
|
||||||
|
recipient=recipient,
|
||||||
|
template=email_template,
|
||||||
|
template_data={
|
||||||
|
"target_url": f"https://my.vbv-afa.ch{target_url}",
|
||||||
|
**template_data,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = notify.send(
|
response = notify.send(
|
||||||
sender=sender,
|
sender=sender,
|
||||||
recipient=recipient,
|
recipient=recipient,
|
||||||
verb=verb,
|
verb=verb,
|
||||||
|
emailed=emailed,
|
||||||
|
# The metadata is saved in the 'data' member of the AbstractNotification model
|
||||||
|
email_template=email_template.name,
|
||||||
|
template_data=template_data,
|
||||||
)
|
)
|
||||||
sent_notification: Notification = response[0][1][0]
|
sent_notification: Notification = response[0][1][0] # 🫨
|
||||||
sent_notification.target_url = target_url
|
sent_notification.target_url = target_url
|
||||||
sent_notification.notification_type = notification_type
|
sent_notification.notification_type = notification_type
|
||||||
sent_notification.course = course
|
sent_notification.course = course
|
||||||
sent_notification.actor_avatar_url = actor_avatar_url
|
sent_notification.actor_avatar_url = actor_avatar_url
|
||||||
sent_notification.emailed = emailed
|
|
||||||
sent_notification.save()
|
sent_notification.save()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.bind(exception=e)
|
log.error("Failed to send notification", exception=str(e))
|
||||||
log.error("Failed to send notification")
|
|
||||||
else:
|
else:
|
||||||
log.info("Notification sent successfully")
|
log.info("Notification sent successfully")
|
||||||
|
|
||||||
|
|
@ -127,13 +194,49 @@ class NotificationService:
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _send_email(recipient: User, verb: str, target_url: Optional[str]) -> bool:
|
def _send_email(
|
||||||
|
recipient: User,
|
||||||
|
template: EmailTemplate,
|
||||||
|
template_data: dict | None,
|
||||||
|
) -> bool:
|
||||||
|
log = logger.bind(
|
||||||
|
recipient=recipient.username,
|
||||||
|
template=template.name,
|
||||||
|
template_data=template_data,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
return EmailService.send_email(
|
EmailService.send_email(
|
||||||
recipient=recipient,
|
recipient=recipient,
|
||||||
verb=verb,
|
template=template,
|
||||||
target_url=target_url,
|
template_data=template_data,
|
||||||
)
|
)
|
||||||
|
log.info("Email sent successfully")
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send email to {recipient}: {e}")
|
log.error(
|
||||||
|
"Failed to send Email", exception=str(e), exc_info=True, stack_info=True
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_duplicate_notification(
|
||||||
|
recipient: User,
|
||||||
|
verb: str,
|
||||||
|
notification_type: NotificationType,
|
||||||
|
template_name: str,
|
||||||
|
template_data: dict,
|
||||||
|
target_url: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""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_type=notification_type,
|
||||||
|
target_url=target_url,
|
||||||
|
data={
|
||||||
|
"email_template": template_name,
|
||||||
|
"template_data": template_data,
|
||||||
|
},
|
||||||
|
).exists()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
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, CourseSessionUser
|
||||||
|
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 (
|
||||||
|
check_attendance_course,
|
||||||
|
)
|
||||||
|
from vbv_lernwelt.notify.service import EmailTemplate
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SentNotification:
|
||||||
|
recipient: User
|
||||||
|
verb: str
|
||||||
|
target_url: str
|
||||||
|
email_template: EmailTemplate
|
||||||
|
template_data: dict
|
||||||
|
|
||||||
|
|
||||||
|
sent_notifications: list[SentNotification] = []
|
||||||
|
|
||||||
|
|
||||||
|
def on_send_notification(**kwargs) -> None:
|
||||||
|
sent_notifications.append(SentNotification(**kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"vbv_lernwelt.notify.service.NotificationService.send_information_notification",
|
||||||
|
on_send_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",
|
||||||
|
)
|
||||||
|
in_one_week = datetime.now() + timedelta(weeks=1)
|
||||||
|
self.csac.due_date.start = timezone.make_aware(
|
||||||
|
in_one_week.replace(hour=7, minute=30, second=0, microsecond=0)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.csac.due_date.end = timezone.make_aware(
|
||||||
|
in_one_week.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",
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
|
def test_happy_day(self):
|
||||||
|
check_attendance_course()
|
||||||
|
self.assertEquals(3, len(sent_notifications))
|
||||||
|
recipients = CourseSessionUser.objects.filter(
|
||||||
|
course_session_id=self.csac.course_session.id
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
set(map(lambda n: n.recipient, sent_notifications)),
|
||||||
|
set(map(lambda csu: csu.user, recipients)),
|
||||||
|
)
|
||||||
|
for notification in sent_notifications:
|
||||||
|
self.assertEquals(
|
||||||
|
_("Erinnerung: Bald findet ein Präsenzkurs statt"),
|
||||||
|
notification.verb,
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
"/course/test-lehrgang/learn/fahrzeug/präsenzkurs-fahrzeug",
|
||||||
|
notification.target_url,
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
self.csac.learning_content.title,
|
||||||
|
notification.template_data["attendance_course"],
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
self.csac.location,
|
||||||
|
notification.template_data["location"],
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
self.csac.trainer,
|
||||||
|
notification.template_data["trainer"],
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
self.csac.due_date.start.strftime("%H:%M %d.%m.%Y"),
|
||||||
|
notification.template_data["start"],
|
||||||
|
)
|
||||||
|
self.assertEquals(
|
||||||
|
self.csac.due_date.end.strftime("%H:%M %d.%m.%Y"),
|
||||||
|
notification.template_data["end"],
|
||||||
|
)
|
||||||
|
|
@ -1,18 +1,25 @@
|
||||||
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.models import Notification, NotificationType
|
||||||
from vbv_lernwelt.notify.service import NotificationService
|
from vbv_lernwelt.notify.service import EmailTemplate, 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"
|
||||||
|
|
@ -36,11 +43,10 @@ class TestNotificationService(TestCase):
|
||||||
recipient=self.recipient,
|
recipient=self.recipient,
|
||||||
verb=verb,
|
verb=verb,
|
||||||
target_url=target_url,
|
target_url=target_url,
|
||||||
|
email_template=EmailTemplate.ATTENDANCE_COURSE_REMINDER,
|
||||||
)
|
)
|
||||||
|
self.assertEqual(1, Notification.objects.count())
|
||||||
notifications: List[Notification] = Notification.objects.all()
|
notification: Notification = Notification.objects.first()
|
||||||
self.assertEqual(1, len(notifications))
|
|
||||||
notification = notifications[0]
|
|
||||||
self.assertEqual(self.admin, notification.actor)
|
self.assertEqual(self.admin, 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)
|
||||||
|
|
@ -54,12 +60,14 @@ class TestNotificationService(TestCase):
|
||||||
target_url = "https://www.vbv.ch"
|
target_url = "https://www.vbv.ch"
|
||||||
course = "Versicherungsvermittler/in"
|
course = "Versicherungsvermittler/in"
|
||||||
self.notification_service.send_progress_notification(
|
self.notification_service.send_progress_notification(
|
||||||
recipient=self.recipient, verb=verb, target_url=target_url, course=course
|
recipient=self.recipient,
|
||||||
|
verb=verb,
|
||||||
|
target_url=target_url,
|
||||||
|
course=course,
|
||||||
|
email_template=EmailTemplate.ATTENDANCE_COURSE_REMINDER,
|
||||||
)
|
)
|
||||||
|
self.assertEqual(1, Notification.objects.count())
|
||||||
notifications: List[Notification] = Notification.objects.all()
|
notification: Notification = Notification.objects.first()
|
||||||
self.assertEqual(1, len(notifications))
|
|
||||||
notification = notifications[0]
|
|
||||||
self.assertEqual(self.admin, notification.actor)
|
self.assertEqual(self.admin, 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)
|
||||||
|
|
@ -77,11 +85,10 @@ class TestNotificationService(TestCase):
|
||||||
verb=verb,
|
verb=verb,
|
||||||
target_url=target_url,
|
target_url=target_url,
|
||||||
course=course,
|
course=course,
|
||||||
|
email_template=EmailTemplate.ATTENDANCE_COURSE_REMINDER,
|
||||||
)
|
)
|
||||||
|
self.assertEqual(1, Notification.objects.count())
|
||||||
notifications: List[Notification] = Notification.objects.all()
|
notification: Notification = Notification.objects.first()
|
||||||
self.assertEqual(1, len(notifications))
|
|
||||||
notification = notifications[0]
|
|
||||||
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)
|
||||||
|
|
@ -90,3 +97,68 @@ class TestNotificationService(TestCase):
|
||||||
str(NotificationType.USER_INTERACTION), notification.notification_type
|
str(NotificationType.USER_INTERACTION), notification.notification_type
|
||||||
)
|
)
|
||||||
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"
|
||||||
|
course = "Versicherungsvermittler/in"
|
||||||
|
|
||||||
|
for i in range(2):
|
||||||
|
self.notification_service.send_user_interaction_notification(
|
||||||
|
sender=self.sender,
|
||||||
|
recipient=self.recipient,
|
||||||
|
verb=verb,
|
||||||
|
target_url=target_url,
|
||||||
|
course=course,
|
||||||
|
email_template=EmailTemplate.ATTENDANCE_COURSE_REMINDER,
|
||||||
|
template_data={
|
||||||
|
"blah": 123,
|
||||||
|
"foo": "ich habe hunger",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, Notification.objects.count())
|
||||||
|
notification: Notification = Notification.objects.first()
|
||||||
|
self.assertEqual(self.sender, notification.actor)
|
||||||
|
self.assertEqual(verb, notification.verb)
|
||||||
|
self.assertEqual(target_url, notification.target_url)
|
||||||
|
self.assertEqual(course, notification.course)
|
||||||
|
self.assertEqual(
|
||||||
|
str(NotificationType.USER_INTERACTION), notification.notification_type
|
||||||
|
)
|
||||||
|
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.recipient.additional_json_data["email_notification_types"] = json.dumps(
|
||||||
|
["INFORMATION"]
|
||||||
|
)
|
||||||
|
self.recipient.save()
|
||||||
|
self.notification_service.send_user_interaction_notification(
|
||||||
|
sender=self.sender,
|
||||||
|
recipient=self.recipient,
|
||||||
|
verb="should not be sent",
|
||||||
|
target_url="",
|
||||||
|
course="",
|
||||||
|
email_template=EmailTemplate.CASEWORK_EVALUATED,
|
||||||
|
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_types"] = json.dumps(
|
||||||
|
["USER_INTERACTION"]
|
||||||
|
)
|
||||||
|
self.recipient.save()
|
||||||
|
self.notification_service.send_user_interaction_notification(
|
||||||
|
sender=self.sender,
|
||||||
|
recipient=self.recipient,
|
||||||
|
verb="should be sent",
|
||||||
|
target_url="",
|
||||||
|
course="",
|
||||||
|
email_template=EmailTemplate.CASEWORK_EVALUATED,
|
||||||
|
template_data={},
|
||||||
|
)
|
||||||
|
self.assertEqual(2, Notification.objects.count())
|
||||||
|
self.assertTrue(self._has_sent_emails())
|
||||||
|
|
|
||||||
|
|
@ -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