Merged in feature/VBV-123 (pull request #216)

Reminder for Assignments

Approved-by: Christian Cueni
This commit is contained in:
Livio Bieri 2023-10-16 14:02:25 +00:00
commit ab7e879973
13 changed files with 604 additions and 25 deletions

View File

@ -8,4 +8,4 @@
0 */1 * * * /usr/local/bin/python /app/manage.py edoniq_import_results
# every day at 19:30
30 19 * * * /usr/local/bin/python /app/manage.py send_attendance_course_reminders
30 19 * * * /usr/local/bin/python /app/manage.py send_email_reminders --type=all

View File

@ -29,6 +29,30 @@ class EmailTemplate(Enum):
"fr": "d-f88d9912e5484e55a879571463e4a166",
}
ASSIGNMENT_REMINDER_CASEWORK_MEMBER = {
"de": "d-8b84fd96213540a796c40d4344bc606f",
"fr": "d-ae6cd87b6b574643b4fff209148c7620",
"it": "d-1bf8b12a70324e1b91050d5fa01ed81f",
}
ASSIGNMENT_REMINDER_PREP_ASSIGNMENT_MEMBER = {
"de": "d-cb866d8c538f4ffaab923022ef7209fa",
"fr": "d-fdc84ae0e1b7417a8ede8db4e07ee7a8",
"it": "d-39d16586341b4559b3a3df71db3d04fb",
}
ASSIGNMENT_REMINDER_EDONIQ_MEMBER = {
"de": "d-4b26911d04834079a64ab1758ca470cc",
"fr": "d-b9f27e3e13e44f20aa5d1a40c93da00d",
"it": "d-1d3d854c5b3e4012ac3d33eeb3d6e7d1",
}
EVALUATION_REMINDER_CASEWORK_EXPERT = {
"de": "d-6e3dd4acc7fc4ce7a2776f5147bd32fd",
"fr": "d-0104add90a354d7fb1fc9fecfa132d06",
"it": "d-630e9316960647768c0a657e175436aa",
}
# VBV - Geleitete Fallarbeit abgegeben
CASEWORK_SUBMITTED = {"de": "d-599f0b35ddcd4fac99314cdf8f5446a2"}

View File

@ -0,0 +1,84 @@
from datetime import timedelta
import structlog
from django.utils import timezone
from vbv_lernwelt.assignment.models import AssignmentType
from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.course_session.models import (
CourseSessionAssignment,
CourseSessionEdoniqTest,
)
from vbv_lernwelt.notify.services import NotificationService
logger = structlog.get_logger(__name__)
ASSIGNMENT_REMINDER_LEAD_TIME = timedelta(days=2)
def send_assignment_reminder_notifications():
start = timezone.now()
end = timezone.now() + ASSIGNMENT_REMINDER_LEAD_TIME
sent = []
# member notifications (CASEWORK and PREP_ASSIGNMENT)
for assignment in CourseSessionAssignment.objects.filter(
submission_deadline__start__lte=end,
submission_deadline__start__gte=start,
learning_content__assignment_type__in=[
AssignmentType.CASEWORK.value,
AssignmentType.PREP_ASSIGNMENT.value,
],
):
for member in CourseSessionUser.objects.filter(
course_session_id=assignment.course_session.id,
role=CourseSessionUser.Role.MEMBER,
):
sent.append(
NotificationService.send_assignment_reminder_notification_member(
recipient=member.user, assignment=assignment
)
)
# member notifications (EDONIQ_TEST)
for edoniq_test in CourseSessionEdoniqTest.objects.filter(
deadline__start__lte=end,
deadline__start__gte=start,
):
for member in CourseSessionUser.objects.filter(
course_session_id=edoniq_test.course_session.id,
role=CourseSessionUser.Role.MEMBER,
):
sent.append(
NotificationService.send_edoniq_test_reminder_notification_member(
recipient=member.user, edoniq_test=edoniq_test
)
)
# expert notifications (CASEWORK)
for assignment in CourseSessionAssignment.objects.filter(
evaluation_deadline__start__lte=end,
evaluation_deadline__start__gte=start,
learning_content__assignment_type__in=[
AssignmentType.CASEWORK.value,
],
):
for expert in CourseSessionUser.objects.filter(
course_session_id=assignment.course_session.id,
role=CourseSessionUser.Role.EXPERT,
):
sent.append(
NotificationService.send_casework_expert_evaluation_reminder(
recipient=expert.user, assignment=assignment
)
)
logger.debug(
"Sent assigment reminders",
start_time=start.isoformat(),
end_time=end.isoformat(),
label="assigment_reminders",
sent=sent,
)
return {"sent": sent}

View File

@ -4,7 +4,6 @@ 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
@ -14,7 +13,7 @@ logger = structlog.get_logger(__name__)
PRESENCE_COURSE_REMINDER_LEAD_TIME = timedelta(weeks=2)
def attendance_course_reminder_notification_job():
def send_attendance_reminder_notifications():
"""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
@ -26,8 +25,8 @@ def attendance_course_reminder_notification_job():
logger.info(
"Querying for attendance courses in specified time range",
start_time=start,
end_time=end,
start_time=start.isoformat(),
end_time=end.isoformat(),
label="attendance_course_reminder_notification_job",
num_attendance_courses=len(attendance_courses),
)
@ -36,8 +35,8 @@ def attendance_course_reminder_notification_job():
csu = CourseSessionUser.objects.filter(course_session_id=cs_id)
logger.info(
"Sending attendance course reminder notification",
start_time=start,
end_time=end,
start_time=start.isoformat(),
end_time=end.isoformat(),
label="attendance_course_reminder_notification_job",
num_users=len(csu),
course_session_id=cs_id,
@ -53,17 +52,3 @@ def attendance_course_reminder_notification_job():
label="attendance_course_reminder_notification_job",
)
return dict(results_counter)
class Command(LoggedCommand):
help = "Sends attendance course reminder notifications to participants"
def handle(self, *args, **options):
results = attendance_course_reminder_notification_job()
self.job_log.json_data = results
self.job_log.save()
logger.info(
"Attendance course reminder notification job finished",
label="attendance_course_reminder_notification_job",
results=results,
)

View File

@ -0,0 +1,56 @@
from enum import Enum
import structlog
from vbv_lernwelt.core.base import LoggedCommand
from vbv_lernwelt.notify.email.reminders.assigment import (
send_assignment_reminder_notifications,
)
from vbv_lernwelt.notify.email.reminders.attendance import (
send_attendance_reminder_notifications,
)
logger = structlog.get_logger(__name__)
class ReminderType(Enum):
ASSIGNMENT = "assignment"
ATTENDANCE = "attendance"
ACTIONS = {
ReminderType.ASSIGNMENT: send_assignment_reminder_notifications,
ReminderType.ATTENDANCE: send_attendance_reminder_notifications,
}
class Command(LoggedCommand):
help = "Sends Email Reminder Notifications"
def add_arguments(self, parser):
parser.add_argument(
"--type",
choices=[t.value for t in ReminderType] + ["all"],
required=True,
help="Type of reminder",
)
def handle(self, *args, **options):
reminder_type = options["type"]
if reminder_type == "all":
types = [ReminderType.ASSIGNMENT, ReminderType.ATTENDANCE]
else:
types = [ReminderType(reminder_type)]
results = {t.value: None for t in types}
for reminder in types:
logger.info(f"Starting {reminder.name} reminder notification job")
results[reminder.value] = ACTIONS[reminder]()
logger.info(f"{reminder.name} reminder notification job finished")
self.job_log.json_data = results
self.job_log.save()
logger.info("Reminder notification job finished")

View File

@ -0,0 +1,31 @@
# Generated by Django 3.2.20 on 2023-10-06 13:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("notify", "0004_alter_notification_notification_trigger"),
]
operations = [
migrations.AlterField(
model_name="notification",
name="notification_trigger",
field=models.CharField(
choices=[
("ATTENDANCE_COURSE_REMINDER", "Attendance Course Reminder"),
("ASSIGNMENT_REMINDER", "Assignment Reminder"),
(
"CASEWORK_EXPERT_EVALUATION_REMINDER",
"Casework Expert Evaluation Reminder",
),
("CASEWORK_SUBMITTED", "Casework Submitted"),
("CASEWORK_EVALUATED", "Casework Evaluated"),
("NEW_FEEDBACK", "New Feedback"),
],
default="",
max_length=255,
),
),
]

View File

@ -15,6 +15,11 @@ class NotificationTrigger(models.TextChoices):
ATTENDANCE_COURSE_REMINDER = "ATTENDANCE_COURSE_REMINDER", _(
"Attendance Course Reminder"
)
ASSIGNMENT_REMINDER = "ASSIGNMENT_REMINDER", _("Assignment Reminder")
CASEWORK_EXPERT_EVALUATION_REMINDER = (
"CASEWORK_EXPERT_EVALUATION_REMINDER",
_("Casework Expert Evaluation Reminder"),
)
CASEWORK_SUBMITTED = "CASEWORK_SUBMITTED", _("Casework Submitted")
CASEWORK_EVALUATED = "CASEWORK_EVALUATED", _("Casework Evaluated")
NEW_FEEDBACK = "NEW_FEEDBACK", _("New Feedback")

View File

@ -6,11 +6,14 @@ import structlog
from django.db.models import Model
from notifications.signals import notify
from vbv_lernwelt.assignment.models import AssignmentType
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.course_session.models import CourseSessionAssignment
from vbv_lernwelt.notify.email.email_services import (
create_template_data_from_course_session_attendance_course,
EmailTemplate,
format_swiss_datetime,
send_email,
)
from vbv_lernwelt.notify.models import (
@ -21,7 +24,10 @@ from vbv_lernwelt.notify.models import (
if TYPE_CHECKING:
from vbv_lernwelt.assignment.models import AssignmentCompletion
from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse
from vbv_lernwelt.course_session.models import (
CourseSessionAttendanceCourse,
CourseSessionEdoniqTest,
)
from vbv_lernwelt.feedback.models import FeedbackResponse
logger = structlog.get_logger(__name__)
@ -137,6 +143,106 @@ class NotificationService:
),
)
@classmethod
def send_assignment_reminder_notification_member(
cls,
recipient: User,
assignment: CourseSessionAssignment,
):
texts = {
"de": "Erinnerung: Bald ist ein Abgabetermin",
"fr": "Rappel: Une date limite approche",
"it": "Promemoria: Una scadenza si avvicina",
}
templates = {
AssignmentType.CASEWORK: EmailTemplate.ASSIGNMENT_REMINDER_CASEWORK_MEMBER,
AssignmentType.PREP_ASSIGNMENT: EmailTemplate.ASSIGNMENT_REMINDER_PREP_ASSIGNMENT_MEMBER,
}
verb = texts.get(recipient.language, "de")
circle = assignment.learning_content.get_parent_circle().title
due_at = assignment.submission_deadline.start
return cls._send_notification(
recipient=recipient,
verb=verb,
notification_category=NotificationCategory.INFORMATION,
notification_trigger=NotificationTrigger.ASSIGNMENT_REMINDER,
target_url=assignment.learning_content.get_frontend_url(),
action_object=assignment,
course_session=assignment.course_session,
email_template=templates[
AssignmentType(assignment.learning_content.assignment_type)
],
template_data={
"circle": circle,
"due_date": format_swiss_datetime(due_at),
},
)
@classmethod
def send_edoniq_test_reminder_notification_member(
cls,
recipient: User,
edoniq_test: CourseSessionEdoniqTest,
):
texts = {
"de": "Erinnerung: Bald ist ein Abgabetermin",
"fr": "Rappel: Une date limite approche",
"it": "Promemoria: Una scadenza si avvicina",
}
verb = texts.get(recipient.language, "de")
circle = edoniq_test.learning_content.get_parent_circle().title
due_at = edoniq_test.deadline.start
return cls._send_notification(
recipient=recipient,
verb=verb,
notification_category=NotificationCategory.INFORMATION,
notification_trigger=NotificationTrigger.ASSIGNMENT_REMINDER,
target_url=edoniq_test.learning_content.get_frontend_url(),
action_object=edoniq_test,
course_session=edoniq_test.course_session,
email_template=EmailTemplate.ASSIGNMENT_REMINDER_EDONIQ_MEMBER,
template_data={
"circle": circle,
"due_date": format_swiss_datetime(due_at),
},
)
@classmethod
def send_casework_expert_evaluation_reminder(
cls,
recipient: User,
assignment: CourseSessionAssignment,
):
texts = {
"de": "Erinnerung: Bald ist ein Bewertungstermin",
"fr": "Rappel: Une date limite approche",
"it": "Promemoria: Una scadenza si avvicina",
}
verb = texts.get(recipient.language, "de")
circle = assignment.learning_content.get_parent_circle().title
due_at = assignment.evaluation_deadline.start
return cls._send_notification(
recipient=recipient,
verb=verb,
notification_category=NotificationCategory.INFORMATION,
notification_trigger=NotificationTrigger.CASEWORK_EXPERT_EVALUATION_REMINDER,
target_url=assignment.evaluation_deadline.url_expert,
action_object=assignment,
course_session=assignment.course_session,
email_template=EmailTemplate.EVALUATION_REMINDER_CASEWORK_EXPERT,
template_data={
"circle": circle,
"due_date": format_swiss_datetime(due_at),
},
)
@classmethod
def _send_notification(
cls,

View File

@ -0,0 +1,287 @@
import re
from datetime import datetime
from typing import Dict
from django.test import TestCase
from django.utils import timezone
from freezegun import freeze_time
from vbv_lernwelt.assignment.models import AssignmentType
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 (
CourseSessionAssignment,
CourseSessionEdoniqTest,
)
from vbv_lernwelt.learnpath.models import (
LearningContentAssignment,
LearningContentEdoniqTest,
)
from vbv_lernwelt.notify.email.email_services import EmailTemplate
from vbv_lernwelt.notify.email.reminders.assigment import (
send_assignment_reminder_notifications,
)
from vbv_lernwelt.notify.models import Notification
EXPECTED_MEMBER_VERB = "Erinnerung: Bald ist ein Abgabetermin"
EXPECTED_EXPERT_VERB = "Erinnerung: Bald ist ein Bewertungstermin"
RECIPIENT_TRAINER = "test-trainer1@example.com"
RECIPIENT_STUDENTS = [
"test-student1@example.com",
"test-student2@example.com",
"test-student3@example.com",
]
ASSIGNMENT_TYPE_LEARNING_CONTENT_LOOKUP: Dict[AssignmentType, str] = {
AssignmentType.CONDITION_ACCEPTANCE: "test-lehrgang-lp-circle-fahrzeug-lc-redlichkeitserklärung",
AssignmentType.PREP_ASSIGNMENT: "test-lehrgang-lp-circle-fahrzeug-lc-fahrzeug-mein-erstes-auto",
AssignmentType.REFLECTION: "test-lehrgang-lp-circle-fahrzeug-lc-reflexion",
AssignmentType.CASEWORK: "test-lehrgang-lp-circle-fahrzeug-lc-überprüfen-einer-motorfahrzeug-versicherungspolice",
}
def create_assignment(
assignment_type: AssignmentType,
submission_deadline=None,
evaluation_deadline=None,
):
assignment = CourseSessionAssignment.objects.create(
course_session=CourseSession.objects.get(id=TEST_COURSE_SESSION_BERN_ID),
learning_content=LearningContentAssignment.objects.get(
slug=ASSIGNMENT_TYPE_LEARNING_CONTENT_LOOKUP[assignment_type]
),
)
assert (
AssignmentType(assignment.learning_content.assignment_type) == assignment_type
)
if submission_deadline:
assignment.submission_deadline.start = submission_deadline
assignment.submission_deadline.end = None
assignment.submission_deadline.save()
if evaluation_deadline:
assignment.evaluation_deadline.start = evaluation_deadline
assignment.evaluation_deadline.end = None
assignment.evaluation_deadline.save()
return assignment
def create_edoniq_test_assignment(deadline_start):
edoniq_test = CourseSessionEdoniqTest.objects.create(
course_session=CourseSession.objects.get(
id=TEST_COURSE_SESSION_BERN_ID,
),
learning_content=LearningContentEdoniqTest.objects.get(
slug="test-lehrgang-lp-circle-fahrzeug-lc-wissens-und-verständnisfragen"
),
)
edoniq_test.deadline.start = deadline_start
edoniq_test.deadline.end = None
edoniq_test.deadline.save()
return edoniq_test
class TestAssignmentCourseRemindersTest(TestCase):
def setUp(self):
create_default_users()
create_test_course(with_sessions=True)
CourseSessionAssignment.objects.all().delete()
CourseSessionEdoniqTest.objects.all().delete()
Notification.objects.all().delete()
def _assert_member_assignment_notifications(
self, action_object, expected_recipients
):
for expected_recipient in expected_recipients:
notification = Notification.objects.get(
recipient__username=expected_recipient
)
self.assertEquals(action_object, notification.action_object)
self.assertEquals("ASSIGNMENT_REMINDER", notification.notification_trigger)
self.assertEquals("INFORMATION", notification.notification_category)
self.assertEquals(EXPECTED_MEMBER_VERB, notification.verb)
template_data = notification.data["template_data"]
self.assertEquals(
action_object.learning_content.get_parent_circle().title,
template_data["circle"],
)
self.assertEquals(
action_object.learning_content.get_frontend_url(),
notification.target_url,
)
match = re.fullmatch(
r"\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}", template_data["due_date"]
)
self.assertIsNotNone(
match, f"due_date format is incorrect: {template_data['due_date']}"
)
email_template = notification.data["email_template"]
# make sure we have the correct email template
if type(action_object) == CourseSessionAssignment:
assignment_type = AssignmentType(
action_object.learning_content.assignment_type
)
if assignment_type == AssignmentType.CASEWORK:
self.assertEquals(
EmailTemplate.ASSIGNMENT_REMINDER_CASEWORK_MEMBER.name,
email_template,
)
elif assignment_type == AssignmentType.PREP_ASSIGNMENT:
self.assertEquals(
EmailTemplate.ASSIGNMENT_REMINDER_PREP_ASSIGNMENT_MEMBER.name,
email_template,
)
elif type(action_object) == CourseSessionEdoniqTest:
self.assertEquals(
EmailTemplate.ASSIGNMENT_REMINDER_EDONIQ_MEMBER.name,
email_template,
)
@freeze_time("2023-01-01")
def test_notification_edoniq(self):
# GIVEN
should_be_sent = create_edoniq_test_assignment(
deadline_start=timezone.make_aware(datetime(2023, 1, 2))
)
# ...too early
create_edoniq_test_assignment(
deadline_start=timezone.make_aware(datetime(2023, 1, 4))
)
# ...too late
create_edoniq_test_assignment(
deadline_start=timezone.make_aware(datetime(2022, 1, 1))
)
# WHEN
send_assignment_reminder_notifications()
# THEN
self.assertEquals(3, len(Notification.objects.all()))
self._assert_member_assignment_notifications(
action_object=should_be_sent,
expected_recipients=RECIPIENT_STUDENTS,
)
with self.assertRaises(Notification.DoesNotExist):
Notification.objects.get(recipient__username=RECIPIENT_TRAINER)
@freeze_time("2023-01-01")
def test_notification_casework_for_members(self):
# GIVEN
casework = create_assignment(
assignment_type=AssignmentType.CASEWORK,
# has a submission deadline within range -> member notification
submission_deadline=timezone.make_aware(datetime(2023, 1, 2)),
# but no evaluation deadline within range -> no expert notification
evaluation_deadline=timezone.make_aware(datetime(2023, 2, 2)),
)
# ...too early
create_assignment(
assignment_type=AssignmentType.CASEWORK,
submission_deadline=timezone.make_aware(datetime(2023, 1, 4)),
evaluation_deadline=timezone.make_aware(datetime(2023, 2, 2)),
)
# ...too late
create_assignment(
assignment_type=AssignmentType.CASEWORK,
submission_deadline=timezone.make_aware(datetime(2022, 1, 1)),
evaluation_deadline=timezone.make_aware(datetime(2022, 2, 2)),
)
# WHEN
send_assignment_reminder_notifications()
# THEN
self.assertEquals(3, len(Notification.objects.all()))
self._assert_member_assignment_notifications(
action_object=casework,
expected_recipients=RECIPIENT_STUDENTS,
)
with self.assertRaises(Notification.DoesNotExist):
Notification.objects.get(recipient__username=RECIPIENT_TRAINER)
@freeze_time("2023-01-01")
def test_notification_casework_for_experts(self):
# GIVEN
casework = create_assignment(
assignment_type=AssignmentType.CASEWORK,
submission_deadline=timezone.make_aware(datetime(2022, 12, 12)),
evaluation_deadline=timezone.make_aware(datetime(2023, 1, 2)),
)
# WHEN
send_assignment_reminder_notifications()
# THEN
self.assertEquals(1, len(Notification.objects.all()))
notification = Notification.objects.get(recipient__username=RECIPIENT_TRAINER)
self.assertEquals(casework, notification.action_object)
self.assertEquals("INFORMATION", notification.notification_category)
self.assertEquals(EXPECTED_EXPERT_VERB, notification.verb)
self.assertEquals(
casework.evaluation_deadline.url_expert, notification.target_url
)
self.assertEquals(
"CASEWORK_EXPERT_EVALUATION_REMINDER", notification.notification_trigger
)
@freeze_time("2023-01-01")
def test_notification_prep_assignment(self):
# GIVEN
prep_assignment = create_assignment(
assignment_type=AssignmentType.PREP_ASSIGNMENT,
submission_deadline=timezone.make_aware(datetime(2023, 1, 2)),
evaluation_deadline=None,
)
# ...too early
create_assignment(
assignment_type=AssignmentType.PREP_ASSIGNMENT,
submission_deadline=timezone.make_aware(datetime(2023, 1, 4)),
evaluation_deadline=None,
)
# ...too late
create_assignment(
assignment_type=AssignmentType.PREP_ASSIGNMENT,
submission_deadline=timezone.make_aware(datetime(2022, 1, 1)),
evaluation_deadline=None,
)
# WHEN
send_assignment_reminder_notifications()
# THEN
self.assertEquals(3, len(Notification.objects.all()))
self._assert_member_assignment_notifications(
action_object=prep_assignment,
expected_recipients=RECIPIENT_STUDENTS,
)
with self.assertRaises(Notification.DoesNotExist):
Notification.objects.get(recipient__username=RECIPIENT_TRAINER)

View File

@ -10,8 +10,8 @@ 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.email.reminders.attendance import (
send_attendance_reminder_notifications,
)
from vbv_lernwelt.notify.models import Notification
@ -63,7 +63,7 @@ class TestAttendanceCourseReminders(TestCase):
)
self.csac_future.due_date.save()
attendance_course_reminder_notification_job()
send_attendance_reminder_notifications()
self.assertEquals(4, len(Notification.objects.all()))
notification = Notification.objects.get(

View File

@ -3,6 +3,7 @@ env_secrets/
env/bitbucket/Dockerfile
env/docker_local.env
server/vbv_lernwelt/assignment/creators/create_assignments.py
server/vbv_lernwelt/notify/email/email_services.py
server/vbv_lernwelt/static/
server/vbv_lernwelt/media/
server/vbv_lernwelt/edoniq_test/certificates/test.key