Merged in feature/VBV-326-auftrag-benachrichtigungen-rebased (pull request #157)

Feature/VBV-326 auftrag benachrichtigungen rebased

* Implement notifications for assignments

# Conflicts:
#	server/vbv_lernwelt/assignment/services.py

* Add cypress test for student assignment submission

* Update django-notifications-hq

* Rework


Approved-by: Daniel Egger
This commit is contained in:
Elia Bieri 2023-07-12 14:32:49 +00:00
parent 24d57577cc
commit 4b0a881055
10 changed files with 130 additions and 30 deletions

View File

@ -75,6 +75,7 @@ function onNotificationClick(notification: Notification) {
<span <span
class="text-left text-sm leading-6 text-black lg:text-base" class="text-left text-sm leading-6 text-black lg:text-base"
style="hyphens: none" style="hyphens: none"
:data-cy="`notification-target-idx-${index}-verb`"
> >
{{ notification.verb }} {{ notification.verb }}
</span> </span>

View File

@ -94,6 +94,7 @@ const onSubmit = async () => {
value: 'value', value: 'value',
checked: state.confirmInput, checked: state.confirmInput,
}" }"
data-cy="confirm-submit-results"
@toggle="state.confirmInput = !state.confirmInput" @toggle="state.confirmInput = !state.confirmInput"
></ItCheckbox> ></ItCheckbox>
<div class="w-full border-b border-gray-400"> <div class="w-full border-b border-gray-400">
@ -104,6 +105,7 @@ const onSubmit = async () => {
value: 'value', value: 'value',
checked: state.confirmPerson, checked: state.confirmPerson,
}" }"
data-cy="confirm-submit-person"
@toggle="state.confirmPerson = !state.confirmPerson" @toggle="state.confirmPerson = !state.confirmPerson"
></ItCheckbox> ></ItCheckbox>
<div class="flex flex-row items-center pb-6 pl-[49px]"> <div class="flex flex-row items-center pb-6 pl-[49px]">
@ -133,13 +135,17 @@ const onSubmit = async () => {
variant="primary" variant="primary"
size="large" size="large"
:disabled="!state.confirmInput || !state.confirmPerson" :disabled="!state.confirmInput || !state.confirmPerson"
data-cy="submit-assignment"
@click="onSubmit" @click="onSubmit"
> >
<p>{{ $t("assignment.submitAssignment") }}</p> <p>{{ $t("assignment.submitAssignment") }}</p>
</ItButton> </ItButton>
</div> </div>
<div v-else class="pt-6"> <div v-else class="pt-6">
<ItSuccessAlert :text="t('assignment.assignmentSubmitted')"></ItSuccessAlert> <ItSuccessAlert
:text="t('assignment.assignmentSubmitted')"
data-cy="success-text"
></ItSuccessAlert>
<p class="pt-6"> <p class="pt-6">
{{ {{
$t("assignment.submissionNotificationDisclaimer", { name: circleExpertName }) $t("assignment.submissionNotificationDisclaimer", { name: circleExpertName })

View File

@ -86,4 +86,27 @@ describe("assignmentStudent.cy.js", () => {
cy.get('[data-cy="nav-progress-step-4"]').click(); cy.get('[data-cy="nav-progress-step-4"]').click();
cy.testLearningContentTitle("Teilaufgabe 4: Deine Empfehlungen"); cy.testLearningContentTitle("Teilaufgabe 4: Deine Empfehlungen");
}); });
it("can submit assignment", () => {
cy.visit(
"/course/test-lehrgang/learn/fahrzeug/überprüfen-einer-motorfahrzeug-versicherungspolice?step=7"
);
cy.get('[data-cy="confirm-submit-results"]').click();
cy.get('[data-cy="confirm-submit-person"]').click();
cy.get('[data-cy="submit-assignment"]').click();
cy.get('[data-cy="success-text"]').should("exist");
// Check if trainer received notification
cy.clearLocalStorage();
cy.clearCookies();
cy.reload(true);
login("test-trainer1@example.com", "test");
cy.visit("/notifications");
cy.get(`[data-cy=notification-idx-0]`).within(() => {
cy.get('[data-cy="unread"]').should("exist");
cy.get('[data-cy="notification-target-idx-0-verb"]').contains(
"Test Student1 hat die geleitete Fallarbeit «Überprüfen einer Motorfahrzeugs-Versicherungspolice» abgegeben."
);
});
});
}); });

View File

@ -1,7 +1,14 @@
export const login = (username, password) => { export const login = (username, password) => {
cy.request({ cy.request({
method: 'POST', method: "POST",
url: '/api/core/login/', url: "/api/core/login/",
body: { username, password }, body: { username, password },
}) });
} };
export const logout = () => {
cy.request({
method: "POST",
url: "/api/core/logout/",
});
};

View File

@ -164,7 +164,7 @@ django-model-utils==4.2.0
# django-notifications-hq # django-notifications-hq
django-modelcluster==6.0 django-modelcluster==6.0
# via wagtail # via wagtail
django-notifications-hq==1.7.0 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

View File

@ -111,7 +111,7 @@ django-model-utils==4.2.0
# django-notifications-hq # django-notifications-hq
django-modelcluster==6.0 django-modelcluster==6.0
# via wagtail # via wagtail
django-notifications-hq==1.7.0 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

View File

@ -8,7 +8,7 @@ from vbv_lernwelt.assignment.graphql.types import AssignmentCompletionObjectType
from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletionStatus from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletionStatus
from vbv_lernwelt.assignment.services import update_assignment_completion from vbv_lernwelt.assignment.services import update_assignment_completion
from vbv_lernwelt.core.models import User from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSession from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.course.permissions import has_course_access, is_course_session_expert from vbv_lernwelt.course.permissions import has_course_access, is_course_session_expert
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@ -55,20 +55,30 @@ class AssignmentCompletionMutation(graphene.Mutation):
): ):
raise PermissionDenied() raise PermissionDenied()
course_session = CourseSession.objects.get(id=course_session_id)
assignment_data = { assignment_data = {
"assignment_user": assignment_user, "assignment_user": assignment_user,
"assignment": assignment, "assignment": assignment,
"course_session": CourseSession.objects.get(id=course_session_id), "course_session": course_session,
"completion_data": json.loads(completion_data_string), "completion_data": json.loads(completion_data_string),
"completion_status": completion_status, "completion_status": completion_status,
} }
if completion_status == AssignmentCompletionStatus.SUBMITTED:
# TODO: determine proper way to assign evaluation user
experts = CourseSessionUser.objects.filter(
course_session=course_session, role="EXPERT"
)
if not experts:
raise PermissionDenied()
assignment_data["evaluation_user"] = experts[0].user
evaluation_data = {} evaluation_data = {}
if completion_status in [ if completion_status in (
AssignmentCompletionStatus.EVALUATION_SUBMITTED, AssignmentCompletionStatus.EVALUATION_SUBMITTED,
AssignmentCompletionStatus.EVALUATION_IN_PROGRESS, AssignmentCompletionStatus.EVALUATION_IN_PROGRESS,
]: ):
if not is_course_session_expert(info.context.user, course_session_id): if not is_course_session_expert(info.context.user, course_session_id):
raise PermissionDenied() raise PermissionDenied()

View File

@ -295,6 +295,13 @@ class AssignmentCompletion(models.Model):
) )
] ]
def get_assignment_evaluation_frontend_url(self):
"""
Used by the expert to evaluate the assignment
Example: /course/überbetriebliche-kurse/cockpit/assignment/371/18
"""
return f"{self.course_session.course.get_cockpit_url()}/assignment/{self.assignment.id}/{self.assignment_user.id}"
class AssignmentCompletionAuditLog(models.Model): class AssignmentCompletionAuditLog(models.Model):
""" """

View File

@ -1,4 +1,5 @@
from copy import deepcopy from copy import deepcopy
from gettext import gettext
from django.utils import timezone from django.utils import timezone
from rest_framework import serializers from rest_framework import serializers
@ -14,6 +15,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
def update_assignment_completion( def update_assignment_completion(
@ -64,7 +66,6 @@ def update_assignment_completion(
) )
if validate_completion_status_change: if validate_completion_status_change:
# TODO: check time?
if completion_status == AssignmentCompletionStatus.SUBMITTED: if completion_status == AssignmentCompletionStatus.SUBMITTED:
if ac.completion_status in [ if ac.completion_status in [
"SUBMITTED", "SUBMITTED",
@ -125,8 +126,38 @@ 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:
verb = gettext(
"%(sender)s hat die geleitete Fallarbeit «%(assignment_title)s» abgegeben."
) % {
"sender": assignment_user.get_full_name(),
"assignment_title": assignment.title,
}
NotificationService.send_user_interaction_notification(
recipient=evaluation_user,
verb=verb,
sender=ac.assignment_user,
course=course_session.course.title,
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()
if learning_content_assignment:
assignment_frontend_url = learning_content_assignment.get_frontend_url()
verb = gettext(
"%(sender)s hat die geleitete Fallarbeit «%(assignment_title)s» bewertet."
) % {
"sender": evaluation_user.get_full_name(),
"assignment_title": assignment.title,
}
NotificationService.send_user_interaction_notification(
recipient=ac.assignment_user,
verb=verb,
sender=evaluation_user,
course=course_session.course.title,
target_url=assignment_frontend_url,
)
ac.completion_status = completion_status.value ac.completion_status = completion_status.value

View File

@ -91,21 +91,32 @@ class NotificationService:
emailed = False emailed = False
if cls._should_send_email(notification_type, recipient): if cls._should_send_email(notification_type, recipient):
emailed = cls._send_email(recipient, verb, target_url) 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( response = notify.send(
sender=sender, sender=sender,
recipient=recipient, recipient=recipient,
verb=verb, verb=verb,
) )
# Custom Notification model fields cannot be set using the notify.send() method.
# https://github.com/django-notifications/django-notifications/issues/301
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.target_url = target_url
sent_notification.actor_avatar_url = actor_avatar_url sent_notification.actor_avatar_url = actor_avatar_url
sent_notification.emailed = emailed sent_notification.emailed = emailed
sent_notification.save() sent_notification.save()
except Exception as e:
log.bind(exception=e)
log.error("Failed to send notification")
else:
log.info("Notification sent successfully")
@staticmethod @staticmethod
def _should_send_email( def _should_send_email(
@ -117,8 +128,12 @@ class NotificationService:
@staticmethod @staticmethod
def _send_email(recipient: User, verb: str, target_url: Optional[str]) -> bool: def _send_email(recipient: User, verb: str, target_url: Optional[str]) -> bool:
try:
return EmailService.send_email( return EmailService.send_email(
recipient=recipient, recipient=recipient,
verb=verb, verb=verb,
target_url=target_url, target_url=target_url,
) )
except Exception as e:
logger.error(f"Failed to send email to {recipient}: {e}")
return False