Skip to content

Commit

Permalink
feat: notifications frontend (#460)
Browse files Browse the repository at this point in the history
* chore: notifications (wip)

* chore: notifications (wip)

* feat: notifications in frontend

* chore: added files

* chore: notification creation

* fix: fixed some weird shizzles

* chore: notifications

* chore: disabled load more button when no notifications

* chore: linting

* chore: removed vitepress cache

* fix: score creation

* chore: linting

---------

Co-authored-by: Topvennie <[email protected]>
  • Loading branch information
EwoutV and Topvennie authored May 23, 2024
1 parent 0c27a32 commit 613fa5a
Show file tree
Hide file tree
Showing 31 changed files with 509 additions and 141 deletions.
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

0 comments on commit 613fa5a

Please sign in to comment.