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:
parent
24d57577cc
commit
4b0a881055
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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/",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue