Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: notifications frontend #460

Merged
merged 12 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions backend/api/permissions/course_permissions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
from typing import cast

from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import AbstractUser

from api.models.course import Course
from api.permissions.role_permissions import (is_assistant, is_student,
is_teacher)
Expand All @@ -12,7 +17,7 @@ class CoursePermission(BasePermission):

def has_permission(self, request: Request, view: ViewSet) -> bool:
"""Check if user has permission to view a general course endpoint."""
user: User = request.user
user: AbstractBaseUser = request.user

# Logged-in users can fetch course information.
if request.method in SAFE_METHODS:
Expand All @@ -23,7 +28,7 @@ def has_permission(self, request: Request, view: ViewSet) -> bool:

def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool:
"""Check if user has permission to view a detailed course endpoint"""
user = request.user
user: User = cast(User, request.user)

# Logged-in users can fetch course details.
if request.method in SAFE_METHODS:
Expand Down
9 changes: 1 addition & 8 deletions backend/api/permissions/group_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,7 @@ def has_object_permission(self, request: Request, view: ViewSet, group) -> bool:
class GroupSubmissionPermission(BasePermission):
"""Permission class for submission related group endpoints"""

def has_permission(self, request: Request, view: APIView) -> bool:
"""Check if user has permission to view a general group submission endpoint."""
user = request.user

# Get the individual permission clauses.
return request.method in SAFE_METHODS or is_teacher(user) or is_assistant(user)

def had_object_permission(self, request: Request, view: ViewSet, group: Group) -> bool:
def had_object_permission(self, request: Request, _: ViewSet, group: Group) -> bool:
"""Check if user has permission to view a detailed group submission endpoint"""
user = request.user
course = group.project.course
Expand Down
18 changes: 11 additions & 7 deletions backend/api/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from api.models.student import Student
from api.models.submission import (ExtraCheckResult, StateEnum,
StructureCheckResult, Submission)
from api.models.teacher import Teacher
from api.tasks.docker_image import (task_docker_image_build,
task_docker_image_remove)
from api.tasks.extra_check import task_extra_check_start
Expand Down Expand Up @@ -35,8 +36,11 @@ def _user_creation(user: User, attributes: dict, **_):
"""Upon user creation, auto-populate additional properties"""
student_id: str = cast(str, attributes.get("ugentStudentID"))

if student_id is not None:
if student_id is None:
Student.create(user, student_id=student_id)
else:
# For now, we assume that everyone without a student ID is a teacher.
Teacher.create(user)


@receiver(run_docker_image_build)
Expand Down Expand Up @@ -120,12 +124,12 @@ def hook_submission(sender, instance: Submission, created: bool, **kwargs):
run_all_checks.send(sender=Submission, submission=instance)
pass

notification_create.send(
sender=Submission,
type=NotificationType.SUBMISSION_RECEIVED,
queryset=list(instance.group.students.all()),
arguments={}
)
notification_create.send(
sender=Submission,
type=NotificationType.SUBMISSION_RECEIVED,
queryset=list(instance.group.students.all()),
arguments={}
)


@receiver(post_save, sender=DockerImage)
Expand Down
2 changes: 1 addition & 1 deletion backend/api/tasks/docker_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def task_docker_image_build(docker_image: DockerImage):
client.images.build(path=MEDIA_ROOT, dockerfile=docker_image.file.path,
tag=get_docker_image_tag(docker_image), rm=True, quiet=True, forcerm=True)
docker_image.state = StateEnum.READY
except (docker.errors.APIError, docker.errors.BuildError, TypeError):
except (docker.errors.BuildError, docker.errors.APIError):
docker_image.state = StateEnum.ERROR
notification_type = NotificationType.DOCKER_IMAGE_BUILD_ERROR
finally:
Expand Down
40 changes: 20 additions & 20 deletions backend/api/views/group_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,26 @@ def submissions(self, request, **_):
)
return Response(serializer.data)

@submissions.mapping.post
@submissions.mapping.put
@swagger_auto_schema(request_body=SubmissionSerializer)
def _add_submission(self, request: Request, **_):
"""Add a submission to the group"""
group: Group = self.get_object()

# Add submission to course
serializer = SubmissionSerializer(
data=request.data, context={"group": group, "request": request}
)

if serializer.is_valid(raise_exception=True):
serializer.save(group=group)

return Response({
"message": gettext("group.success.submissions.add"),
"submission": serializer.data
})

@students.mapping.post
@students.mapping.put
@swagger_auto_schema(request_body=StudentJoinGroupSerializer)
Expand Down Expand Up @@ -110,23 +130,3 @@ def _remove_student(self, request, **_):
return Response({
"message": gettext("group.success.students.remove"),
})

@submissions.mapping.post
@submissions.mapping.put
@swagger_auto_schema(request_body=SubmissionSerializer)
def _add_submission(self, request: Request, **_):
"""Add a submission to the group"""
group: Group = self.get_object()

# Add submission to course
serializer = SubmissionSerializer(
data=request.data, context={"group": group, "request": request}
)

if serializer.is_valid(raise_exception=True):
serializer.save(group=group)

return Response({
"message": gettext("group.success.submissions.add"),
"submission": serializer.data
})
24 changes: 15 additions & 9 deletions backend/api/views/user_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,22 +79,28 @@ def search(self, request: Request) -> Response:
@action(detail=True, methods=["get"], permission_classes=[NotificationPermission])
def notifications(self, request: Request, pk: str):
"""Returns a list of notifications for the given user"""
notifications = Notification.objects.filter(user=pk)
count = min(
int(request.query_params.get("count", 10)), 30
)

# Get the notifications for the user
notifications = Notification.objects.filter(user=pk, is_read=False).order_by("-created_at")

if notifications.count() < count:
notifications = list(notifications) + list(
Notification.objects.filter(user=pk, is_read=True).order_by("-created_at")[:count - notifications.count()]
)

# Serialize the notifications
serializer = NotificationSerializer(
notifications, many=True, context={"request": request}
)

return Response(serializer.data)

@action(
detail=True,
methods=["post"],
permission_classes=[NotificationPermission],
url_path="notifications/read",
)
def read(self, _: Request, pk: str):
@notifications.mapping.patch
def _read_notifications(self, _: Request, pk: str):
"""Marks all notifications as read for the given user"""
notifications = Notification.objects.filter(user=pk)
notifications.update(is_read=True)

return Response(status=HTTP_200_OK)
12 changes: 8 additions & 4 deletions backend/authentication/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from typing import Tuple

from rest_framework.relations import HyperlinkedIdentityField

from authentication.cas.client import client
from authentication.models import User
from authentication.signals import user_created, user_login
Expand Down Expand Up @@ -35,7 +37,7 @@ def validate(self, data):

# Update the user's last login.
if api_settings.UPDATE_LAST_LOGIN:
update_last_login(self, user)
update_last_login(CASTokenObtainSerializer, user)

# Login and send authentication signals.
if "request" in self.context:
Expand Down Expand Up @@ -95,11 +97,13 @@ class UserSerializer(ModelSerializer):
This serializer validates the user fields for creation and updating.
"""
faculties = HyperlinkedRelatedField(
many=True, read_only=True, view_name="faculty-detail"
view_name="faculty-detail",
many=True,
read_only=True
)

notifications = HyperlinkedRelatedField(
view_name="notification-detail",
notifications = HyperlinkedIdentityField(
view_name="user-notifications",
read_only=True,
)

Expand Down
4 changes: 2 additions & 2 deletions backend/notifications/locale/en/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ msgstr "Your score has been updated.\nNew score: %(score)s"
msgid "Title: Docker image build success"
msgstr "Docker image successfully build"
msgid "Description: Docker image build success %(name)s"
msgstr "Your docker image, $(name)s, has successfully been build"
msgstr "Your docker image, %(name)s, has successfully been build"
# Docker Image Build Error
msgid "Title: Docker image build error"
msgstr "Docker image failed to build"
Expand All @@ -44,7 +44,7 @@ msgstr "Failed to build your docker image, %(name)s"
msgid "Title: Extra check success"
msgstr "Passed an extra check"
msgid "Description: Extra check success %(name)s"
msgstr "Your submission passed the extra check, $(name)s"
msgstr "Your submission passed the extra check, %(name)s"
# Extra Check Error
msgid "Title: Extra check error"
msgstr "Failed an extra check"
Expand Down
4 changes: 2 additions & 2 deletions backend/notifications/locale/nl/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ msgstr "Je score is geupdate.\nNieuwe score: %(score)s"
msgid "Title: Docker image build success"
msgstr "Docker image succesvol gebouwd"
msgid "Description: Docker image build success %(name)s"
msgstr "Jouw docker image, $(name)s, is succesvol gebouwd"
msgstr "Jouw docker image, %(name)s, is succesvol gebouwd"
# Docker Image Build Error
msgid "Title: Docker image build error"
msgstr "Docker image is gefaald om te bouwen"
Expand All @@ -44,7 +44,7 @@ msgstr "Gefaald om jouw docker image, %(name)s, te bouwen"
msgid "Title: Extra check success"
msgstr "Geslaagd voor een extra check"
msgid "Description: Extra check success %(name)s"
msgstr "Jouw indiening is geslaagd voor de extra check: $(name)s"
msgstr "Jouw indiening is geslaagd voor de extra check: %(name)s"
# Extra Check Error
msgid "Title: Extra check error"
msgstr "Gefaald voor een extra check"
Expand Down
15 changes: 9 additions & 6 deletions backend/notifications/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,26 @@
from ypovoli.settings import EMAIL_CUSTOM


# Returns a dictionary with the title and description of the notification
def get_message_dict(notification: Notification) -> Dict[str, str]:
"""Get the message from the template and arguments."""
return {
"title": _(notification.template_id.title_key),
"description": _(notification.template_id.description_key)
% notification.arguments,
}


# Call the function after 60 seconds and no more than once in that period
def schedule_send_mails():
"""Schedule the sending of emails."""
if not cache.get("notifications_send_mails", False):
cache.set("notifications_send_mails", True)
_send_mails.apply_async(countdown=60)


# Try to send one email and set the result
def _send_mail(mail: mail.EmailMessage, result: List[bool]):
def _send_mail(message: mail.EmailMessage, result: List[bool]):
"""Try to send one email and set the result."""
try:
mail.send(fail_silently=False)
message.send(fail_silently=False)
result[0] = True
except SMTPException:
result[0] = False
Expand All @@ -40,11 +40,13 @@ def _send_mail(mail: mail.EmailMessage, result: List[bool]):
# TODO: Move to tasks module
# TODO: Retry 3
# https://docs.celeryq.dev/en/v5.3.6/getting-started/next-steps.html#next-steps
# Send all unsent emails
@shared_task()
def _send_mails():
"""Send all unsent emails."""

# All notifications that need to be sent
notifications = Notification.objects.filter(is_sent=False)

# Dictionary with the number of errors for each email
errors: DefaultDict[str, int] = cache.get(
"notifications_send_mails_errors", defaultdict(int)
Expand Down Expand Up @@ -105,5 +107,6 @@ def _send_mails():
# Restart the process if there are any notifications left that were not sent
unsent_notifications = Notification.objects.filter(is_sent=False)
cache.set("notifications_send_mails", False)

if unsent_notifications.count() > 0:
schedule_send_mails()
21 changes: 21 additions & 0 deletions backend/notifications/migrations/0002_alter_notification_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 5.0.4 on 2024-05-23 10:51

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('notifications', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AlterField(
model_name='notification',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL),
),
]
48 changes: 37 additions & 11 deletions backend/notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,53 @@


class NotificationTemplate(models.Model):
id = models.AutoField(auto_created=True, primary_key=True)
title_key = models.CharField(max_length=255) # Key used to get translated title
"""This model represents a template for a notification."""
id = models.AutoField(
auto_created=True,
primary_key=True
)
title_key = models.CharField(
max_length=255
)
description_key = models.CharField(
max_length=511
) # Key used to get translated description
)


class Notification(models.Model):
id = models.AutoField(auto_created=True, primary_key=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
template_id = models.ForeignKey(NotificationTemplate, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
arguments = models.JSONField(default=dict) # Arguments to be used in the template
"""This model represents a notification."""
id = models.AutoField(
auto_created=True,
primary_key=True
)

user = models.ForeignKey(
User,
related_name="notifications",
on_delete=models.CASCADE
)

template_id = models.ForeignKey(
NotificationTemplate,
on_delete=models.CASCADE
)
created_at = models.DateTimeField(
auto_now_add=True
)
# Arguments to be used in the template
arguments = models.JSONField(
default=dict
)
# Whether the notification has been read
is_read = models.BooleanField(
default=False
) # Whether the notification has been read
)
# Whether the notification has been sent (email)
is_sent = models.BooleanField(
default=False
) # Whether the notification has been sent (email)
)

# Mark the notification as read
def sent(self):
"""Mark the notification as sent"""
self.is_sent = True
self.save()
Loading