Skip to content

Commit

Permalink
BITMAKER-2971: Add actions notifications to estela (#188)
Browse files Browse the repository at this point in the history
Related: #142 

* Add Notification model
* Add notification message and notification handler
* Add notification views
* Show a brief preview on the header notification icon
* Add inbox notifications page
* Add seen field for every user notification
* Fix case when a superuser tried to add itself to a project
* When clicking a notification, mark it as seen

---------

Co-authored-by: José Galdos Chávez <jgaldoschavez@gmail.com>
Co-authored-by: Raymond Negron <raymond1242@Raymonds-MacBook-Air.local>
Co-authored-by: mgonnav <mateo@emegona.com>
  • Loading branch information
4 people committed Jun 12, 2023
1 parent e153738 commit 2da9074
Show file tree
Hide file tree
Showing 43 changed files with 1,735 additions and 362 deletions.
9 changes: 9 additions & 0 deletions estela-api/api/errors.py
Expand Up @@ -15,3 +15,12 @@
PAGE_NOT_FOUND = "Page not found."
INSUFFICIENT_PERMISSIONS = "You do not have the {} permissions to perform this action."
USER_NOT_FOUND = "User not found."
UNABLE_CHANGE_PASSWORD = "Unable to change password."
ERROR_SENDING_VERIFICATION_EMAIL = (
"Your user was created but there was an error sending"
" the verification email. Please try to log in later."
)
UNAUTHORIZED_PROFILE = "You are unauthorized to see this user profile."
SEND_EMAIL_LATER = (
"There was an error sending the verification email. Please try again later."
)
14 changes: 14 additions & 0 deletions estela-api/api/mixins.py
Expand Up @@ -5,6 +5,7 @@
from rest_framework.permissions import IsAuthenticated

from api.permissions import IsProjectUser, IsAdminOrReadOnly
from core.models import Notification


class APIPageNumberPagination(PageNumberPagination):
Expand All @@ -19,3 +20,16 @@ class BaseViewSet(viewsets.GenericViewSet):
authentication_classes = [TokenAuthentication]
permission_classes = [IsAuthenticated, IsProjectUser, IsAdminOrReadOnly]
pagination_class = APIPageNumberPagination


class NotificationsHandlerMixin:
def save_notification(self, user, message, project):
notification = Notification(
message=message,
user=user,
project=project,
)
notification.save()
for _user in project.users.all():
notification.users.add(_user, through_defaults={"seen": False})
notification.save()
28 changes: 20 additions & 8 deletions estela-api/api/serializers/cronjob.py
Expand Up @@ -2,6 +2,8 @@
from rest_framework import serializers

from api import errors

from api.mixins import NotificationsHandlerMixin
from api.serializers.job_specific import (
SpiderJobArgSerializer,
SpiderJobEnvVarSerializer,
Expand Down Expand Up @@ -110,7 +112,9 @@ def create(self, validated_data):
return cronjob


class SpiderCronJobUpdateSerializer(serializers.ModelSerializer):
class SpiderCronJobUpdateSerializer(
serializers.ModelSerializer, NotificationsHandlerMixin
):
def validate(self, attrs):
attrs = super(SpiderCronJobUpdateSerializer, self).validate(attrs)
if "schedule" in attrs and not croniter.is_valid(attrs.get("schedule")):
Expand All @@ -131,37 +135,45 @@ class Meta:
)

def update(self, instance, validated_data):
user = instance["user"]
instance = instance["object"]
status = validated_data.get("status", "")
schedule = validated_data.get("schedule", "")
unique_collection = validated_data.get("unique_collection", False)
data_status = validated_data.get("data_status", "")
data_expiry_days = int(validated_data.get("data_expiry_days", 1))
name = instance.name
message = ""
if "schedule" in validated_data:
instance.schedule = schedule
update_schedule(name, schedule)
message = f"changed the schedule of Scheduled-job-{instance.cjid}."
if "status" in validated_data:
instance.status = status
if status == SpiderCronJob.ACTIVE_STATUS:
enable_cronjob(name)
message = f"enabled Scheduled-job-{instance.cjid}."
elif status == SpiderCronJob.DISABLED_STATUS:
disable_cronjob(name)
message = f"disabled Scheduled-job-{instance.cjid}."
if "unique_collection" in validated_data:
instance.unique_collection = unique_collection
if "data_status" in validated_data:
if data_status == DataStatus.PERSISTENT_STATUS:
instance.data_status = DataStatus.PERSISTENT_STATUS
elif data_status == DataStatus.PENDING_STATUS:
message = f"changed data persistence of Scheduled-job-{instance.cjid} to persistent."
elif data_status == DataStatus.PENDING_STATUS and data_expiry_days > 0:
instance.data_status = DataStatus.PENDING_STATUS
if data_expiry_days < 1:
raise serializers.ValidationError(
{"error": errors.POSITIVE_SMALL_INTEGER_FIELD}
)
else:
instance.data_expiry_days = data_expiry_days
instance.data_expiry_days = data_expiry_days
message = f"changed data persistence of Scheduled-job-{instance.cjid} to {data_expiry_days} days."
else:
raise serializers.ValidationError({"error": errors.INVALID_DATA_STATUS})

self.save_notification(
user=user,
message=message,
project=instance.spider.project,
)
instance.save()
return instance

Expand Down
60 changes: 60 additions & 0 deletions estela-api/api/serializers/notification.py
@@ -0,0 +1,60 @@
from rest_framework import serializers

from api.serializers.project import ProjectDetailSerializer, UserDetailSerializer
from core.models import Notification, UserNotification


class NotificationSerializer(serializers.ModelSerializer):
user = UserDetailSerializer(
required=True, help_text="User who performed the action."
)
project = ProjectDetailSerializer(
required=True, help_text="Project where the action was performed."
)

class Meta:
model = Notification
fields = ("nid", "message", "user", "project")


class UserNotificationSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(
required=True, help_text="Unique user notification ID."
)
notification = NotificationSerializer(
required=True, help_text="Notification to which the user is subscribed."
)

class Meta:
model = UserNotification
fields = ("id", "notification", "seen", "created")

def to_representation(self, instance):
ret = super().to_representation(instance)
user = instance.notification.user

is_superuser = user.is_superuser or user.is_staff
user_in_project = instance.notification.project.users.filter(id=user.id)
if is_superuser and not user_in_project:
ret["notification"]["user"]["username"] = "Bitmaker Cloud Admin"
else:
ret["notification"]["user"][
"username"
] = user.username
return ret


class UserNotificationUpdateSerializer(serializers.ModelSerializer):
seen = serializers.BooleanField(
required=False,
help_text="Whether the user has seen the notification.",
)

class Meta:
model = UserNotification
fields = ("id", "seen")

def update(self, instance, validated_data):
instance.seen = validated_data.get("seen", instance.seen)
instance.save()
return instance
17 changes: 17 additions & 0 deletions estela-api/api/serializers/project.py
Expand Up @@ -20,6 +20,20 @@ class Meta:
fields = ["user", "permission"]


class ProjectDetailSerializer(serializers.ModelSerializer):
pid = serializers.UUIDField(
read_only=True, help_text="A UUID identifying this project."
)
name = serializers.CharField(read_only=True, help_text="Project name.")

class Meta:
model = Project
fields = (
"pid",
"name",
)


class ProjectSerializer(serializers.ModelSerializer):
pid = serializers.UUIDField(
read_only=True, help_text="A UUID identifying this project."
Expand Down Expand Up @@ -109,6 +123,9 @@ class ProjectUpdateSerializer(serializers.ModelSerializer):
email = serializers.EmailField(
write_only=True, required=False, help_text="Email address."
)
name = serializers.CharField(
write_only=True, required=False, help_text="Project name."
)
action = serializers.ChoiceField(
write_only=True,
choices=ACTION_CHOICES,
Expand Down
24 changes: 16 additions & 8 deletions estela-api/api/urls.py
@@ -1,20 +1,28 @@
from rest_framework import routers

from api.views import auth as auth_views
from api.views import cronjob as cronjob_views
from api.views import deploy as deploy_views
from api.views import job as job_views
from api.views import job_data as job_data_views
from api.views import project as project_views
from api.views import spider as spider_views
from api.views import stats as stats_views
from api.views import (
project as project_views,
deploy as deploy_views,
spider as spider_views,
job as job_views,
auth as auth_views,
cronjob as cronjob_views,
job_data as job_data_views,
stats as stats_views,
notification as notification_views,
)

router = routers.DefaultRouter(trailing_slash=False)
router.register(
prefix=r"projects",
viewset=project_views.ProjectViewSet,
basename="project",
)
router.register(
prefix=r"notifications",
viewset=notification_views.UserNotificationViewSet,
basename="notification",
)
router.register(
prefix=r"projects/(?P<pid>[0-9a-z-]+)/deploys",
viewset=deploy_views.DeployViewSet,
Expand Down
17 changes: 4 additions & 13 deletions estela-api/api/views/auth.py
Expand Up @@ -17,6 +17,7 @@
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
from rest_framework.response import Response

from api import errors
from api.exceptions import EmailServiceError, UserNotFoundError
from api.permissions import IsProfileUser
from api.serializers.auth import (
Expand Down Expand Up @@ -85,11 +86,7 @@ def register(self, request, *args, **kwargs):
try:
send_verification_email(user, request)
except Exception:
raise EmailServiceError(
{
"error": "Your user was created but there was an error sending the verification email. Please try to log in later."
}
)
raise EmailServiceError({"error": errors.ERROR_SENDING_VERIFICATION_EMAIL})
token, _ = Token.objects.get_or_create(user=user)
return Response(TokenSerializer(token).data)

Expand Down Expand Up @@ -161,9 +158,7 @@ def retrieve(self, request, *args, **kwargs):
)
if user != requested_user:
return Response(
data={
"error": "Unauthorized to see this profile, you are allowed to see only your profile."
},
data={"error": errors.UNAUTHORIZED_PROFILE},
status=status.HTTP_401_UNAUTHORIZED,
)

Expand Down Expand Up @@ -263,11 +258,7 @@ def request(self, request, *args, **kwargs):
try:
send_change_password_email(user)
except Exception:
raise EmailServiceError(
{
"error": "There was an error sending the password reset email. Please try again later."
}
)
raise EmailServiceError({"error": errors.SEND_EMAIL_LATER})
token, _ = Token.objects.get_or_create(user=user)
return Response(TokenSerializer(token).data)

Expand Down
15 changes: 13 additions & 2 deletions estela-api/api/views/cronjob.py
Expand Up @@ -8,18 +8,19 @@
from rest_framework.response import Response

from api.filters import SpiderCronJobFilter
from api.mixins import BaseViewSet
from api.mixins import BaseViewSet, NotificationsHandlerMixin
from api.serializers.cronjob import (
SpiderCronJobCreateSerializer,
SpiderCronJobSerializer,
SpiderCronJobUpdateSerializer,
)
from core.cronjob import create_cronjob, disable_cronjob, run_cronjob_once
from core.models import DataStatus, Spider, SpiderCronJob
from core.models import DataStatus, Spider, SpiderCronJob, Project


class SpiderCronJobViewSet(
BaseViewSet,
NotificationsHandlerMixin,
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
Expand Down Expand Up @@ -93,6 +94,15 @@ def create(self, request, *args, **kwargs):
cronjob.schedule,
data_expiry_days=data_expiry_days,
)

# Send notification action
project = get_object_or_404(Project, pid=self.kwargs["pid"])
self.save_notification(
user=request.user,
message=f"scheduled a new Scheduled-job-{cronjob.cjid} for spider {spider.name}.",
project=project,
)

headers = self.get_success_headers(serializer.data)
return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
Expand All @@ -105,6 +115,7 @@ def create(self, request, *args, **kwargs):
def update(self, request, *args, **kwargs):
partial = kwargs.pop("partial", False)
instance = self.get_object()
instance = {"object": self.get_object(), "user": request.user}
serializer = SpiderCronJobUpdateSerializer(
instance, data=request.data, partial=partial
)
Expand Down
11 changes: 10 additions & 1 deletion estela-api/api/views/deploy.py
Expand Up @@ -4,7 +4,7 @@
from rest_framework.response import Response
from rest_framework.exceptions import ParseError, APIException, PermissionDenied

from api.mixins import BaseViewSet
from api.mixins import BaseViewSet, NotificationsHandlerMixin
from api.serializers.deploy import (
DeploySerializer,
DeployCreateSerializer,
Expand All @@ -19,6 +19,7 @@

class DeployViewSet(
BaseViewSet,
NotificationsHandlerMixin,
viewsets.ModelViewSet,
):
model_class = Deploy
Expand Down Expand Up @@ -91,5 +92,13 @@ def update(self, request, *args, **kwargs):
if getattr(instance, "_prefetched_objects_cache", None):
instance._prefetched_objects_cache = {}

# Send action notification
project = get_object_or_404(Project, pid=self.kwargs["pid"])
self.save_notification(
user=instance.user,
message=f"made a new Deploy #{serializer.data['did']}.",
project=project,
)

headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_200_OK, headers=headers)

0 comments on commit 2da9074

Please sign in to comment.