mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-17 18:32:35 +00:00
Notification v1: create notification panel and show mentions in comments
This commit is contained in:
parent
df49a243e0
commit
c5c79744ce
85 changed files with 4776 additions and 84 deletions
backend
src/baserow
api
contrib/database/rows
core
apps.pyhandler.py
migrations
models.pynotification_types.pynotifications
__init__.pyexceptions.pyhandler.pymodels.pyoperations.pyreceivers.pyregistries.pyservice.pysignals.py
permission_manager.pyprosemirror
signals.pytest_utils/fixtures
ws
tests/baserow
changelog/entries/unreleased/feature
enterprise/backend/tests/baserow_enterprise_tests/role
premium
backend
web-frontend/modules/baserow_premium
web-frontend
locales
modules
core
assets/scss/components
components
NotificationPanel.vue
editor
modals
notifications
BaserowVersionUpgradeNotification.vueNotificationImgIcon.vueNotificationSenderInitialsIcon.vueWorkspaceInvitationAcceptedNotification.vueWorkspaceInvitationCreatedNotification.vueWorkspaceInvitationRejectedNotification.vue
sidebar
workspace
editor
locales
mixins
notificationTypes.jspages
plugin.jsplugins
services
store
database/utils
test/unit/core/store
0
backend/src/baserow/api/notifications/__init__.py
Normal file
0
backend/src/baserow/api/notifications/__init__.py
Normal file
7
backend/src/baserow/api/notifications/errors.py
Normal file
7
backend/src/baserow/api/notifications/errors.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
from rest_framework.status import HTTP_404_NOT_FOUND
|
||||
|
||||
ERROR_NOTIFICATION_DOES_NOT_EXIST = (
|
||||
"ERROR_NOTIFICATION_DOES_NOT_EXIST",
|
||||
HTTP_404_NOT_FOUND,
|
||||
"The requested notification does not exist.",
|
||||
)
|
67
backend/src/baserow/api/notifications/serializers.py
Normal file
67
backend/src/baserow/api/notifications/serializers.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from baserow.core.notifications.models import NotificationRecipient, User
|
||||
|
||||
|
||||
class SenderSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("id", "username", "first_name")
|
||||
|
||||
|
||||
class NotificationRecipientSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize notification data along with the recipient information about the
|
||||
read status for the given user.
|
||||
"""
|
||||
|
||||
id = serializers.IntegerField(
|
||||
source="notification.id", help_text="The id of the notification."
|
||||
)
|
||||
type = serializers.CharField(
|
||||
source="notification.type",
|
||||
help_text="The type of notification",
|
||||
)
|
||||
sender = SenderSerializer(
|
||||
source="notification.sender", help_text="The user that sent the notification."
|
||||
)
|
||||
data = serializers.JSONField(
|
||||
source="notification.data",
|
||||
help_text="The data associated with the notification.",
|
||||
)
|
||||
workspace = serializers.SerializerMethodField(
|
||||
read_only=True, help_text="The workspace that the notification is in (if any)."
|
||||
)
|
||||
created_on = serializers.DateTimeField(
|
||||
source="notification.created_on",
|
||||
help_text="The date and time that the notification was created.",
|
||||
)
|
||||
|
||||
def get_workspace(self, instance):
|
||||
if instance.workspace_id:
|
||||
return {"id": instance.workspace_id}
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
model = NotificationRecipient
|
||||
fields = (
|
||||
"id",
|
||||
"type",
|
||||
"sender",
|
||||
"workspace",
|
||||
"created_on",
|
||||
"read",
|
||||
"data",
|
||||
)
|
||||
|
||||
|
||||
class NotificationUpdateSerializer(serializers.Serializer):
|
||||
read = serializers.BooleanField(
|
||||
help_text="If True, then the notification has been read by the recipient.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
fields = ("read",)
|
||||
extra_kwargs = {
|
||||
"read": {"read_only": True},
|
||||
}
|
23
backend/src/baserow/api/notifications/urls.py
Normal file
23
backend/src/baserow/api/notifications/urls.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
from django.urls import re_path
|
||||
|
||||
from .views import NotificationMarkAllAsReadView, NotificationsView, NotificationView
|
||||
|
||||
app_name = "baserow.api.notifications"
|
||||
|
||||
urlpatterns = [
|
||||
re_path(
|
||||
r"^(?P<workspace_id>[0-9]+)/$",
|
||||
NotificationsView.as_view(),
|
||||
name="list",
|
||||
),
|
||||
re_path(
|
||||
r"^(?P<workspace_id>[0-9]+)/mark-all-as-read/$",
|
||||
NotificationMarkAllAsReadView.as_view(),
|
||||
name="mark_all_as_read",
|
||||
),
|
||||
re_path(
|
||||
r"^(?P<workspace_id>[0-9]+)/(?P<notification_id>[0-9]+)/$",
|
||||
NotificationView.as_view(),
|
||||
name="item",
|
||||
),
|
||||
]
|
16
backend/src/baserow/api/notifications/user_data_types.py
Normal file
16
backend/src/baserow/api/notifications/user_data_types.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
from baserow.api.user.registries import UserDataType
|
||||
from baserow.core.notifications.handler import NotificationHandler
|
||||
|
||||
|
||||
class UnreadUserNotificationsCountPermissionsDataType(UserDataType):
|
||||
type = "user_notifications"
|
||||
|
||||
def get_user_data(self, user, request) -> dict:
|
||||
"""
|
||||
Responsible for annotating `User` responses with the number of unread
|
||||
user notifications. User notifications are direct (non-broadcast)
|
||||
notifications sent to the user and not bound to a specific workspace.
|
||||
"""
|
||||
|
||||
unread_count = NotificationHandler.get_unread_notifications_count(user)
|
||||
return {"unread_count": unread_count}
|
208
backend/src/baserow/api/notifications/views.py
Normal file
208
backend/src/baserow/api/notifications/views.py
Normal file
|
@ -0,0 +1,208 @@
|
|||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.status import HTTP_204_NO_CONTENT
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from baserow.api.decorators import validate_body
|
||||
from baserow.api.errors import ERROR_GROUP_DOES_NOT_EXIST, ERROR_USER_NOT_IN_GROUP
|
||||
from baserow.api.notifications.errors import ERROR_NOTIFICATION_DOES_NOT_EXIST
|
||||
from baserow.api.schemas import get_error_schema
|
||||
from baserow.api.serializers import get_example_pagination_serializer_class
|
||||
from baserow.api.utils import map_exceptions
|
||||
from baserow.core.exceptions import UserNotInWorkspace, WorkspaceDoesNotExist
|
||||
from baserow.core.notifications.exceptions import NotificationDoesNotExist
|
||||
from baserow.core.notifications.service import NotificationService
|
||||
|
||||
from .serializers import NotificationRecipientSerializer, NotificationUpdateSerializer
|
||||
|
||||
|
||||
class NotificationsView(APIView):
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="workspace_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description="The workspace id that the notifications belong to.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="limit",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Defines how many notifications should be returned.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="offset",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description=(
|
||||
"Defines the offset of the notifications that should be returned."
|
||||
),
|
||||
),
|
||||
],
|
||||
tags=["Notifications"],
|
||||
operation_id="list_workspace_notifications",
|
||||
description=(
|
||||
"Lists the notifications for the given workspace and the current user. "
|
||||
"The response is paginated and the limit and offset parameters can be "
|
||||
"controlled using the query parameters."
|
||||
),
|
||||
responses={
|
||||
200: get_example_pagination_serializer_class(
|
||||
NotificationRecipientSerializer
|
||||
),
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
404: get_error_schema(["ERROR_GROUP_DOES_NOT_EXIST"]),
|
||||
},
|
||||
)
|
||||
@map_exceptions(
|
||||
{
|
||||
UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP,
|
||||
WorkspaceDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST,
|
||||
}
|
||||
)
|
||||
def get(self, request: Request, workspace_id: int) -> Response:
|
||||
"""
|
||||
Lists the notifications for the given workspace for the current user.
|
||||
The response is paginated and the limit and offset can be controlled
|
||||
using the query parameters.
|
||||
"""
|
||||
|
||||
paginator = LimitOffsetPagination()
|
||||
paginator.max_limit = settings.ROW_PAGE_SIZE_LIMIT
|
||||
paginator.default_limit = settings.ROW_PAGE_SIZE_LIMIT
|
||||
|
||||
notifications = NotificationService.list_notifications(
|
||||
request.user, workspace_id
|
||||
)
|
||||
|
||||
page = paginator.paginate_queryset(notifications, request, self)
|
||||
return paginator.get_paginated_response(
|
||||
NotificationRecipientSerializer(page, many=True).data
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="workspace_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description="The workspace the notifications are in.",
|
||||
),
|
||||
],
|
||||
tags=["Notifications"],
|
||||
operation_id="clear_workspace_notifications",
|
||||
description=("Clear all the notifications for the given workspace and user."),
|
||||
responses={
|
||||
204: None,
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
404: get_error_schema(["ERROR_GROUP_DOES_NOT_EXIST"]),
|
||||
},
|
||||
)
|
||||
@transaction.atomic
|
||||
@map_exceptions(
|
||||
{
|
||||
UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP,
|
||||
WorkspaceDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST,
|
||||
}
|
||||
)
|
||||
def delete(self, request: Request, workspace_id: int) -> Response:
|
||||
"""
|
||||
Delete all the notifications for the given workspace and user.
|
||||
"""
|
||||
|
||||
NotificationService.clear_all_notifications(request.user, workspace_id)
|
||||
return Response(status=HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class NotificationView(APIView):
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="workspace_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description="The workspace the notification is in.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="notification_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description="The notification id to update.",
|
||||
),
|
||||
],
|
||||
tags=["Notifications"],
|
||||
operation_id="mark_notification_as_read",
|
||||
description=("Marks a notification as read."),
|
||||
responses={
|
||||
200: NotificationRecipientSerializer,
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
404: get_error_schema(
|
||||
["ERROR_GROUP_DOES_NOT_EXIST", "NOTIFICATION_DOES_NOT_EXIST"]
|
||||
),
|
||||
},
|
||||
)
|
||||
@transaction.atomic
|
||||
@map_exceptions(
|
||||
{
|
||||
UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP,
|
||||
WorkspaceDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST,
|
||||
NotificationDoesNotExist: ERROR_NOTIFICATION_DOES_NOT_EXIST,
|
||||
}
|
||||
)
|
||||
@validate_body(NotificationUpdateSerializer)
|
||||
def patch(
|
||||
self, request: Request, data, workspace_id: int, notification_id: int
|
||||
) -> Response:
|
||||
"""
|
||||
Updates the notification with the given id.
|
||||
"""
|
||||
|
||||
notification_recipient = NotificationService.mark_notification_as_read(
|
||||
request.user, workspace_id, notification_id, data["read"]
|
||||
)
|
||||
|
||||
return Response(NotificationRecipientSerializer(notification_recipient).data)
|
||||
|
||||
|
||||
class NotificationMarkAllAsReadView(APIView):
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="workspace_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description="The workspace the notifications are in.",
|
||||
),
|
||||
],
|
||||
tags=["Notifications"],
|
||||
operation_id="mark_all_workspace_notifications_as_read",
|
||||
description=(
|
||||
"Mark as read all the notifications for the given workspace and user."
|
||||
),
|
||||
responses={
|
||||
204: None,
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
404: get_error_schema(["ERROR_GROUP_DOES_NOT_EXIST"]),
|
||||
},
|
||||
)
|
||||
@transaction.atomic
|
||||
@map_exceptions(
|
||||
{
|
||||
UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP,
|
||||
WorkspaceDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST,
|
||||
}
|
||||
)
|
||||
def post(self, request: Request, workspace_id: int) -> Response:
|
||||
"""
|
||||
Marks all notifications as read for the given workspace and user.
|
||||
"""
|
||||
|
||||
NotificationService.mark_all_notifications_as_read(request.user, workspace_id)
|
||||
return Response(status=HTTP_204_NO_CONTENT)
|
|
@ -88,7 +88,11 @@ def get_client_undo_redo_action_group_id(user: AbstractUser):
|
|||
|
||||
|
||||
def set_user_websocket_id(user, request):
|
||||
user.web_socket_id = request.headers.get(settings.WEBSOCKET_ID_HEADER)
|
||||
_set_user_websocket_id(user, request.headers.get(settings.WEBSOCKET_ID_HEADER))
|
||||
|
||||
|
||||
def _set_user_websocket_id(user, websocket_id):
|
||||
user.web_socket_id = websocket_id
|
||||
|
||||
|
||||
def get_user_remote_ip_address_from_request(request):
|
||||
|
|
|
@ -14,6 +14,7 @@ from .auth_provider import urls as auth_provider_urls
|
|||
from .health import urls as health_urls
|
||||
from .integrations import urls as integrations_urls
|
||||
from .jobs import urls as jobs_urls
|
||||
from .notifications import urls as notifications_urls
|
||||
from .settings import urls as settings_urls
|
||||
from .snapshots import urls as snapshots_urls
|
||||
from .spectacular.views import CachedSpectacularJSONAPIView
|
||||
|
@ -55,6 +56,7 @@ urlpatterns = (
|
|||
path(
|
||||
"templates/", include(templates_compat_urls, namespace="templates_compat")
|
||||
),
|
||||
path("notifications/", include(notifications_urls, namespace="notifications")),
|
||||
]
|
||||
+ application_type_registry.api_urls
|
||||
+ plugin_registry.api_urls
|
||||
|
|
|
@ -133,6 +133,10 @@ class WorkspaceUserWorkspaceSerializer(serializers.Serializer):
|
|||
permissions = serializers.CharField(
|
||||
read_only=True, help_text="The requesting user's permissions for the workspace."
|
||||
)
|
||||
unread_notifications_count = serializers.IntegerField(
|
||||
read_only=True,
|
||||
help_text="The number of unread notifications for the requesting user.",
|
||||
)
|
||||
|
||||
|
||||
class UpdateWorkspaceUserSerializer(serializers.ModelSerializer):
|
||||
|
|
|
@ -34,6 +34,7 @@ from baserow.core.exceptions import (
|
|||
WorkspaceUserIsLastAdmin,
|
||||
)
|
||||
from baserow.core.handler import CoreHandler
|
||||
from baserow.core.notifications.handler import NotificationHandler
|
||||
from baserow.core.trash.exceptions import CannotDeleteAlreadyDeletedItem
|
||||
|
||||
from .errors import ERROR_GROUP_USER_IS_LAST_ADMIN
|
||||
|
@ -68,6 +69,13 @@ class WorkspacesView(APIView):
|
|||
.get_workspaceuser_workspace_queryset()
|
||||
.filter(user=request.user)
|
||||
)
|
||||
|
||||
workspaceuser_workspaces = (
|
||||
NotificationHandler.annotate_workspaces_with_unread_notifications_count(
|
||||
request.user, workspaceuser_workspaces, outer_ref_key="workspace_id"
|
||||
)
|
||||
)
|
||||
|
||||
serializer = WorkspaceUserWorkspaceSerializer(
|
||||
workspaceuser_workspaces, many=True
|
||||
)
|
||||
|
|
|
@ -867,9 +867,7 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)):
|
|||
getattr(row, name).set(value)
|
||||
|
||||
row.save()
|
||||
rows_updated_counter.add(
|
||||
1,
|
||||
)
|
||||
rows_updated_counter.add(1)
|
||||
|
||||
update_collector = FieldUpdateCollector(
|
||||
table,
|
||||
|
|
|
@ -80,6 +80,11 @@ class CoreConfig(AppConfig):
|
|||
subject_type_registry.register(UserSubjectType())
|
||||
subject_type_registry.register(AnonymousUserSubjectType())
|
||||
|
||||
from .notifications.operations import (
|
||||
ClearNotificationsOperationType,
|
||||
ListNotificationsOperationType,
|
||||
MarkNotificationAsReadOperationType,
|
||||
)
|
||||
from .operations import (
|
||||
CreateApplicationsWorkspaceOperationType,
|
||||
CreateInvitationsWorkspaceOperationType,
|
||||
|
@ -118,6 +123,9 @@ class CoreConfig(AppConfig):
|
|||
ReadWorkspaceTrashOperationType,
|
||||
)
|
||||
|
||||
operation_type_registry.register(ClearNotificationsOperationType())
|
||||
operation_type_registry.register(ListNotificationsOperationType())
|
||||
operation_type_registry.register(MarkNotificationAsReadOperationType())
|
||||
operation_type_registry.register(CreateApplicationsWorkspaceOperationType())
|
||||
operation_type_registry.register(CreateWorkspaceOperationType())
|
||||
operation_type_registry.register(CreateInvitationsWorkspaceOperationType())
|
||||
|
@ -244,10 +252,14 @@ class CoreConfig(AppConfig):
|
|||
job_type_registry.register(CreateSnapshotJobType())
|
||||
job_type_registry.register(RestoreSnapshotJobType())
|
||||
|
||||
from baserow.api.notifications.user_data_types import (
|
||||
UnreadUserNotificationsCountPermissionsDataType,
|
||||
)
|
||||
from baserow.api.user.registries import user_data_registry
|
||||
from baserow.api.user.user_data_types import GlobalPermissionsDataType
|
||||
|
||||
user_data_registry.register(GlobalPermissionsDataType())
|
||||
user_data_registry.register(UnreadUserNotificationsCountPermissionsDataType())
|
||||
|
||||
from baserow.core.auth_provider.auth_provider_types import (
|
||||
PasswordAuthProviderType,
|
||||
|
@ -256,6 +268,24 @@ class CoreConfig(AppConfig):
|
|||
|
||||
auth_provider_type_registry.register(PasswordAuthProviderType())
|
||||
|
||||
import baserow.core.notifications.receivers # noqa: F401
|
||||
from baserow.core.notification_types import (
|
||||
WorkspaceInvitationAcceptedNotificationType,
|
||||
WorkspaceInvitationCreatedNotificationType,
|
||||
WorkspaceInvitationRejectedNotificationType,
|
||||
)
|
||||
from baserow.core.notifications.registries import notification_type_registry
|
||||
|
||||
notification_type_registry.register(
|
||||
WorkspaceInvitationAcceptedNotificationType()
|
||||
)
|
||||
notification_type_registry.register(
|
||||
WorkspaceInvitationCreatedNotificationType()
|
||||
)
|
||||
notification_type_registry.register(
|
||||
WorkspaceInvitationRejectedNotificationType()
|
||||
)
|
||||
|
||||
self._setup_health_checks()
|
||||
|
||||
# Clear the key after migration so we will trigger a new template sync.
|
||||
|
|
|
@ -88,6 +88,9 @@ from .signals import (
|
|||
before_workspace_user_updated,
|
||||
workspace_created,
|
||||
workspace_deleted,
|
||||
workspace_invitation_accepted,
|
||||
workspace_invitation_created,
|
||||
workspace_invitation_rejected,
|
||||
workspace_updated,
|
||||
workspace_user_added,
|
||||
workspace_user_deleted,
|
||||
|
@ -1024,6 +1027,15 @@ class CoreHandler(metaclass=baserow_trace_methods(tracer)):
|
|||
},
|
||||
)
|
||||
|
||||
try:
|
||||
invited_user = User.objects.get(email=invitation.email)
|
||||
except User.DoesNotExist:
|
||||
invited_user = None
|
||||
|
||||
workspace_invitation_created.send(
|
||||
sender=self, invitation=invitation, invited_user=invited_user
|
||||
)
|
||||
|
||||
self.send_workspace_invitation_email(invitation, base_url)
|
||||
|
||||
return invitation
|
||||
|
@ -1101,6 +1113,10 @@ class CoreHandler(metaclass=baserow_trace_methods(tracer)):
|
|||
"user."
|
||||
)
|
||||
|
||||
workspace_invitation_rejected.send(
|
||||
sender=self, invitation=invitation, user=user
|
||||
)
|
||||
|
||||
invitation.delete()
|
||||
|
||||
def add_user_to_workspace(
|
||||
|
@ -1165,6 +1181,9 @@ class CoreHandler(metaclass=baserow_trace_methods(tracer)):
|
|||
invitation.workspace, user, permissions=invitation.permissions
|
||||
)
|
||||
|
||||
workspace_invitation_accepted.send(
|
||||
sender=self, invitation=invitation, user=user
|
||||
)
|
||||
invitation.delete()
|
||||
|
||||
return workspace_user
|
||||
|
|
190
backend/src/baserow/core/migrations/0072_notifications.py
Normal file
190
backend/src/baserow/core/migrations/0072_notifications.py
Normal file
|
@ -0,0 +1,190 @@
|
|||
# Generated by Django 3.2.18 on 2023-07-13 09:01
|
||||
|
||||
import django.contrib.postgres.indexes
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("core", "0071_trashentry_trash_item_owner"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Notification",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_on",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="The date and time when the notification was created.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
help_text="The type of notification.", max_length=64
|
||||
),
|
||||
),
|
||||
(
|
||||
"broadcast",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="If True, then the notification will be sent to all users. A broadcast notification will not immediately create all the NotificationRecipient objects, but will do so when the notification is read or cleared by a user.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"data",
|
||||
models.JSONField(
|
||||
default=dict, help_text="The data of the notification."
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_on"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="NotificationRecipient",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"read",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="If True, then the notification has been read by the user. ",
|
||||
),
|
||||
),
|
||||
(
|
||||
"cleared",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="If True, then the notification has been cleared by the user. Cleared notifications will not be visible by the user anymore.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_on",
|
||||
models.DateTimeField(
|
||||
help_text="A copy of the notification created_on field needed to speed up queries."
|
||||
),
|
||||
),
|
||||
(
|
||||
"broadcast",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="A copy of the notification broadcast field needed to speed up queries.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace_id",
|
||||
models.BigIntegerField(
|
||||
help_text="A copy of the notification workspace_id field needed to speed up queries.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"notification",
|
||||
models.ForeignKey(
|
||||
help_text="The notification that will be sent to the recipient.",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="core.notification",
|
||||
),
|
||||
),
|
||||
(
|
||||
"recipient",
|
||||
models.ForeignKey(
|
||||
help_text="The user that will receive the notification.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_on"],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notification",
|
||||
name="recipients",
|
||||
field=models.ManyToManyField(
|
||||
help_text="The users that will receive the notification. For broadcast notifications, the recipients will be created when the notification is read or cleared by the user.For direct notifications, the recipients will be created immediately when the notification is created.",
|
||||
related_name="notifications",
|
||||
through="core.NotificationRecipient",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notification",
|
||||
name="sender",
|
||||
field=models.ForeignKey(
|
||||
help_text="The user that will receive the notification.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="sent_notifications",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notification",
|
||||
name="workspace",
|
||||
field=models.ForeignKey(
|
||||
help_text="The workspace where the notification lives.If the notification is a broadcast notification, then the workspace will be None.Workspace can be null also if the notification is not associated with a specific workspace or if the user does not have access to the workspace yet, like for a workspace invitation.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to="core.workspace",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="notificationrecipient",
|
||||
index=models.Index(
|
||||
fields=["-created_on"], name="core_notifi_created_06e5e6_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="notificationrecipient",
|
||||
index=models.Index(
|
||||
condition=models.Q(("cleared", False), ("read", False)),
|
||||
fields=["broadcast", "cleared", "read", "recipient_id", "workspace_id"],
|
||||
include=("notification_id",),
|
||||
name="unread_notif_count_idx",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="notificationrecipient",
|
||||
unique_together={("notification", "recipient")},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="notification",
|
||||
index=models.Index(
|
||||
fields=["-created_on"], name="core_notifi_created_8ed1ac_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="notification",
|
||||
index=django.contrib.postgres.indexes.GinIndex(
|
||||
fields=["data"], name="notification_data"
|
||||
),
|
||||
),
|
||||
]
|
|
@ -27,6 +27,7 @@ from .mixins import (
|
|||
PolymorphicContentTypeMixin,
|
||||
TrashableModelMixin,
|
||||
)
|
||||
from .notifications.models import Notification
|
||||
from .services.models import Service
|
||||
|
||||
__all__ = [
|
||||
|
@ -46,6 +47,7 @@ __all__ = [
|
|||
"InstallTemplateJob",
|
||||
"Integration",
|
||||
"Service",
|
||||
"Notification",
|
||||
]
|
||||
|
||||
|
||||
|
|
128
backend/src/baserow/core/notification_types.py
Normal file
128
backend/src/baserow/core/notification_types.py
Normal file
|
@ -0,0 +1,128 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from baserow.core.notifications.exceptions import NotificationDoesNotExist
|
||||
from baserow.core.notifications.handler import NotificationHandler
|
||||
from baserow.core.notifications.registries import NotificationType
|
||||
|
||||
from .signals import (
|
||||
workspace_invitation_accepted,
|
||||
workspace_invitation_created,
|
||||
workspace_invitation_rejected,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InvitationNotificationData:
|
||||
invitation_id: int
|
||||
invited_to_workspace_id: int
|
||||
invited_to_workspace_name: str
|
||||
|
||||
|
||||
def mark_invitation_notification_as_read(user, invitation):
|
||||
try:
|
||||
notification = NotificationHandler.get_notification_by(
|
||||
user,
|
||||
notificationrecipient__read=False,
|
||||
data__contains={"invitation_id": invitation.id},
|
||||
)
|
||||
except NotificationDoesNotExist:
|
||||
return
|
||||
|
||||
NotificationHandler.mark_notification_as_read(
|
||||
user, notification, include_user_in_signal=True
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceInvitationCreatedNotificationType(NotificationType):
|
||||
type = "workspace_invitation_created"
|
||||
|
||||
@classmethod
|
||||
def create_invitation_created_notification(cls, invitation, invited_user):
|
||||
NotificationHandler.create_notification_for_users(
|
||||
notification_type=cls.type,
|
||||
recipients=[invited_user],
|
||||
sender=invitation.invited_by,
|
||||
data=asdict(
|
||||
InvitationNotificationData(
|
||||
invitation.id, invitation.workspace.id, invitation.workspace.name
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@receiver(workspace_invitation_created)
|
||||
def handle_workspace_invitation_created(
|
||||
sender, invitation, invited_user=None, **kwargs
|
||||
):
|
||||
if invited_user:
|
||||
WorkspaceInvitationCreatedNotificationType.create_invitation_created_notification(
|
||||
invitation, invited_user
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceInvitationAcceptedNotificationType(NotificationType):
|
||||
type = "workspace_invitation_accepted"
|
||||
|
||||
@classmethod
|
||||
def create_invitation_accepted_notification(cls, user, invitation):
|
||||
NotificationHandler.create_notification_for_users(
|
||||
notification_type=cls.type,
|
||||
recipients=[invitation.invited_by],
|
||||
sender=user,
|
||||
data=asdict(
|
||||
InvitationNotificationData(
|
||||
invitation.id, invitation.workspace.id, invitation.workspace.name
|
||||
)
|
||||
),
|
||||
workspace=invitation.workspace,
|
||||
)
|
||||
|
||||
|
||||
@receiver(workspace_invitation_accepted)
|
||||
def handle_workspace_invitation_accepted(sender, invitation, user, **kwargs):
|
||||
mark_invitation_notification_as_read(user, invitation)
|
||||
WorkspaceInvitationAcceptedNotificationType.create_invitation_accepted_notification(
|
||||
user, invitation
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceInvitationRejectedNotificationType(NotificationType):
|
||||
type = "workspace_invitation_rejected"
|
||||
|
||||
@classmethod
|
||||
def create_invitation_rejected_notification(cls, user, invitation):
|
||||
NotificationHandler.create_notification_for_users(
|
||||
notification_type=cls.type,
|
||||
recipients=[invitation.invited_by],
|
||||
sender=user,
|
||||
data=asdict(
|
||||
InvitationNotificationData(
|
||||
invitation.id, invitation.workspace.id, invitation.workspace.name
|
||||
)
|
||||
),
|
||||
workspace=invitation.workspace,
|
||||
)
|
||||
|
||||
|
||||
@receiver(workspace_invitation_rejected)
|
||||
def handle_workspace_invitation_rejected(sender, invitation, user, **kwargs):
|
||||
mark_invitation_notification_as_read(user, invitation)
|
||||
WorkspaceInvitationRejectedNotificationType.create_invitation_rejected_notification(
|
||||
user, invitation
|
||||
)
|
||||
|
||||
|
||||
class BaserowVersionUpgradeNotificationType(NotificationType):
|
||||
type = "baserow_version_upgrade"
|
||||
|
||||
@classmethod
|
||||
def create_version_upgrade_broadcast_notification(
|
||||
cls, version, release_notes_url=None
|
||||
):
|
||||
NotificationHandler.create_broadcast_notification(
|
||||
notification_type=cls.type,
|
||||
sender=None,
|
||||
data={"version": version, "release_notes_url": release_notes_url},
|
||||
)
|
0
backend/src/baserow/core/notifications/__init__.py
Normal file
0
backend/src/baserow/core/notifications/__init__.py
Normal file
5
backend/src/baserow/core/notifications/exceptions.py
Normal file
5
backend/src/baserow/core/notifications/exceptions.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from .models import Notification
|
||||
|
||||
|
||||
class NotificationDoesNotExist(Notification.DoesNotExist):
|
||||
pass
|
517
backend/src/baserow/core/notifications/handler.py
Normal file
517
backend/src/baserow/core/notifications/handler.py
Normal file
|
@ -0,0 +1,517 @@
|
|||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db.models import Count, OuterRef, Q, QuerySet, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from opentelemetry import trace
|
||||
|
||||
from baserow.core.models import Workspace
|
||||
from baserow.core.telemetry.utils import baserow_trace
|
||||
from baserow.core.utils import grouper
|
||||
|
||||
from .exceptions import NotificationDoesNotExist
|
||||
from .models import Notification, NotificationRecipient
|
||||
from .signals import (
|
||||
all_notifications_cleared,
|
||||
all_notifications_marked_as_read,
|
||||
notification_created,
|
||||
notification_marked_as_read,
|
||||
)
|
||||
|
||||
tracer = trace.get_tracer(__name__)
|
||||
|
||||
|
||||
class NotificationHandler:
|
||||
@classmethod
|
||||
def _get_unread_broadcast_q(cls, user: AbstractUser) -> Q:
|
||||
user_broadcast = NotificationRecipient.objects.filter(
|
||||
broadcast=True, recipient=user
|
||||
)
|
||||
unread_broadcast = Q(broadcast=True, recipient=None) & ~Q(
|
||||
notification_id__in=user_broadcast.values("notification_id")
|
||||
)
|
||||
return unread_broadcast
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def get_notification_by_id(
|
||||
cls, user: AbstractUser, notification_id: int
|
||||
) -> NotificationRecipient:
|
||||
"""
|
||||
Get a notification for the given user matching the given notification
|
||||
id.
|
||||
|
||||
:param user: The user to get the notification for.
|
||||
:param notification_id: The id of the notification.
|
||||
:return: The notification recipient instance.
|
||||
:raises BaseNotificationDoesNotExist: When the notification with the
|
||||
given id does not exist or the given user is not a recipient for it.
|
||||
"""
|
||||
|
||||
return cls.get_notification_by(user, id=notification_id)
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def get_notification_by(cls, user: AbstractUser, **kwargs) -> Notification:
|
||||
"""
|
||||
Get a notification for the given user matching the given kwargs.
|
||||
|
||||
:param user: The user to get the notification for.
|
||||
:return: The notification instance.
|
||||
:raises BaseNotificationDoesNotExist: When the notification with the
|
||||
given id does not exist or the given user is not a recipient for it.
|
||||
"""
|
||||
|
||||
unread_broadcast = cls._get_unread_broadcast_q(user)
|
||||
|
||||
notification_ids = NotificationRecipient.objects.filter(
|
||||
Q(recipient=user, cleared=False) | unread_broadcast
|
||||
).values("notification_id")
|
||||
|
||||
try:
|
||||
return Notification.objects.filter(id__in=notification_ids, **kwargs).get()
|
||||
except Notification.DoesNotExist:
|
||||
raise NotificationDoesNotExist("Notification does not exist.")
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def all_notifications_for_user(cls, user, workspace: Optional[Workspace] = None):
|
||||
workspace_filter = Q(workspace_id=None)
|
||||
if workspace:
|
||||
workspace_filter |= Q(workspace_id=workspace.id)
|
||||
|
||||
direct = Q(broadcast=False, recipient=user) & workspace_filter
|
||||
uncleared_broadcast = Q(broadcast=True, recipient=user, cleared=False)
|
||||
unread_broadcast = cls._get_unread_broadcast_q(user)
|
||||
|
||||
return NotificationRecipient.objects.filter(
|
||||
direct | uncleared_broadcast | unread_broadcast
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def list_notifications(cls, user, workspace: Workspace):
|
||||
"""
|
||||
Returns a list of notifications for the given user and workspace.
|
||||
Broadcast notifications recipients are missing for the unread notifications,
|
||||
so we need to return them excluding the ones the user has already cleared.
|
||||
|
||||
|
||||
:param user: The user to get the notifications for.
|
||||
:param workspace: The workspace to get the notifications for.
|
||||
:return: A list of notifications.
|
||||
"""
|
||||
|
||||
return cls.all_notifications_for_user(user, workspace).select_related(
|
||||
"notification", "notification__sender"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def get_unread_notifications_count(
|
||||
cls, user: AbstractUser, workspace: Optional[Workspace] = None
|
||||
) -> int:
|
||||
"""
|
||||
Returns the number of unread notifications for the given user.
|
||||
|
||||
:param user: The user to count the notifications for.
|
||||
:param workspace: The workspace to count the notifications for.
|
||||
:return: The number of unread notifications.
|
||||
"""
|
||||
|
||||
workspace_q = Q(workspace_id=None)
|
||||
if workspace:
|
||||
workspace_q |= Q(workspace_id=workspace.id)
|
||||
|
||||
unread_direct = Q(broadcast=False, recipient=user, read=False) & workspace_q
|
||||
unread_broadcast = cls._get_unread_broadcast_q(user)
|
||||
|
||||
return NotificationRecipient.objects.filter(
|
||||
unread_direct | unread_broadcast
|
||||
).count()
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def annotate_workspaces_with_unread_notifications_count(
|
||||
cls, user: AbstractUser, workspace_queryset: QuerySet, outer_ref_key: str = "pk"
|
||||
) -> QuerySet:
|
||||
"""
|
||||
Annotates the given workspace queryset with the number of unread notifications
|
||||
for the given user.
|
||||
|
||||
:param user: The user to count the notifications for.
|
||||
:param workspace_queryset: The workspace queryset to annotate.
|
||||
:param outer_ref_key: The key to use for the outer ref.
|
||||
:return: The annotated workspace queryset.
|
||||
"""
|
||||
|
||||
notification_qs = NotificationRecipient.objects.filter(
|
||||
recipient=user,
|
||||
workspace_id=OuterRef(outer_ref_key),
|
||||
broadcast=False,
|
||||
read=False,
|
||||
cleared=False,
|
||||
)
|
||||
|
||||
subquery = Subquery(
|
||||
notification_qs.values("workspace_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
return workspace_queryset.annotate(
|
||||
unread_notifications_count=Coalesce(subquery, 0)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def _get_missing_broadcast_entries_for_user(
|
||||
cls,
|
||||
user: AbstractUser,
|
||||
) -> QuerySet[NotificationRecipient]:
|
||||
"""
|
||||
Because broadcast entries are created for user only when they mark them
|
||||
as read or cleared, this function returns the missing broadcast entries
|
||||
for the given user.
|
||||
|
||||
:param user: The user to get the notifications for.
|
||||
:return: The missing broadcast notification recipients for the given
|
||||
user.
|
||||
"""
|
||||
|
||||
unread_broadcasts = cls._get_unread_broadcast_q(user)
|
||||
|
||||
return NotificationRecipient.objects.filter(unread_broadcasts)
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def _create_missing_entries_for_broadcast_notifications_with_defaults(
|
||||
cls, user: AbstractUser, read=False, cleared=False, **kwargs
|
||||
):
|
||||
"""
|
||||
Broadcast entries might be missing because are created only when the
|
||||
user mark them as read or cleared, so let's create them and mark them as
|
||||
cleared so they don't show up anymore but also they are not recreated
|
||||
when the user clears all notifications again.
|
||||
|
||||
:param user: The user to create the NotificationRecipient for.
|
||||
:param read: If True, the created NotificationRecipient will be marked as read.
|
||||
:param cleared: If True, the created NotificationRecipient will be marked as
|
||||
cleared.
|
||||
:param kwargs: Extra kwargs to pass to the NotificationRecipient constructor.
|
||||
:return: None
|
||||
"""
|
||||
|
||||
missing_broadcasts_entries = cls._get_missing_broadcast_entries_for_user(user)
|
||||
|
||||
batch_size = 2500
|
||||
for missing_entries_chunk in grouper(batch_size, missing_broadcasts_entries):
|
||||
NotificationRecipient.objects.bulk_create(
|
||||
[
|
||||
NotificationRecipient(
|
||||
recipient_id=user.id,
|
||||
notification_id=empty_entry.notification_id,
|
||||
created_on=empty_entry.created_on,
|
||||
broadcast=empty_entry.broadcast,
|
||||
workspace_id=empty_entry.workspace_id,
|
||||
read=read,
|
||||
cleared=cleared,
|
||||
**kwargs,
|
||||
)
|
||||
for empty_entry in missing_entries_chunk
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def clear_all_notifications(
|
||||
cls,
|
||||
user: AbstractUser,
|
||||
workspace: Workspace,
|
||||
send_signal: bool = True,
|
||||
):
|
||||
"""
|
||||
Clears all the notifications for the given user and workspace.
|
||||
|
||||
:param user: The user to clear the notifications for.
|
||||
:param workspace: The workspace to clear the notifications for.
|
||||
:param send_signal: Whether to send the signal or not.
|
||||
"""
|
||||
|
||||
cls._create_missing_entries_for_broadcast_notifications_with_defaults(
|
||||
user, cleared=True
|
||||
)
|
||||
|
||||
# clear also read broadcast notifications
|
||||
NotificationRecipient.objects.filter(
|
||||
broadcast=True, recipient=user, cleared=False
|
||||
).update(cleared=True)
|
||||
|
||||
# direct notifications can be deleted if there are no more recipients
|
||||
uncleared_direct = NotificationRecipient.objects.filter(
|
||||
Q(workspace_id=workspace.pk) | Q(workspace_id=None),
|
||||
recipient=user,
|
||||
broadcast=False,
|
||||
cleared=False,
|
||||
)
|
||||
|
||||
Notification.objects.annotate(recipient_count=Count("recipients")).filter(
|
||||
Q(workspace_id=workspace.pk) | Q(workspace_id=None),
|
||||
broadcast=False,
|
||||
recipient_count=1,
|
||||
id__in=uncleared_direct.values("notification_id"),
|
||||
).delete()
|
||||
|
||||
# delete the ones that still have other recipients
|
||||
uncleared_direct.delete()
|
||||
|
||||
if send_signal:
|
||||
all_notifications_cleared.send(sender=cls, user=user, workspace=workspace)
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def mark_notification_as_read(
|
||||
cls,
|
||||
user: AbstractUser,
|
||||
notification: Notification,
|
||||
read: bool = True,
|
||||
send_signal: bool = True,
|
||||
include_user_in_signal: bool = False,
|
||||
) -> NotificationRecipient:
|
||||
"""
|
||||
Marks a notification as read for the given user and returns the updated
|
||||
notification instance.
|
||||
|
||||
:param user: The user to mark the notifications as read for.
|
||||
:param notification: The notification to mark as read.
|
||||
:param send_signal: If True, then the notification_marked_as_read signal
|
||||
is sent.
|
||||
:param read: If True, the notification will be marked as read, otherwise
|
||||
it will be marked as unread.
|
||||
:param include_user_in_signal: Since the notification can be
|
||||
automatically marked as read by the system, this parameter can be
|
||||
used to include the user session in the real time event.
|
||||
:return: The notification instance updated.
|
||||
"""
|
||||
|
||||
notification_recipient, _ = NotificationRecipient.objects.update_or_create(
|
||||
notification=notification,
|
||||
recipient=user,
|
||||
defaults={
|
||||
"read": read,
|
||||
"workspace_id": notification.workspace_id,
|
||||
"broadcast": notification.broadcast,
|
||||
"created_on": notification.created_on,
|
||||
},
|
||||
)
|
||||
|
||||
if send_signal:
|
||||
# If the notification is marked as read by the system, then we
|
||||
# want to send the signal to the current websocket as well
|
||||
ignore_web_socket_id = getattr(user, "web_socket_id", None)
|
||||
if include_user_in_signal:
|
||||
ignore_web_socket_id = None
|
||||
|
||||
notification_marked_as_read.send(
|
||||
sender=cls,
|
||||
notification=notification,
|
||||
notification_recipient=notification_recipient,
|
||||
user=user,
|
||||
ignore_web_socket_id=ignore_web_socket_id,
|
||||
)
|
||||
|
||||
return notification_recipient
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def mark_all_notifications_as_read(
|
||||
cls,
|
||||
user: AbstractUser,
|
||||
workspace: Workspace,
|
||||
send_signal: bool = True,
|
||||
):
|
||||
"""
|
||||
Marks all the notifications as read for the given workspace and user.
|
||||
|
||||
:param user: The user to mark the notifications as read for.
|
||||
:param workspace: The workspace to filter the notifications by.
|
||||
:param send_signal: If True, then the all_notifications_marked_as_read
|
||||
signal is sent.
|
||||
"""
|
||||
|
||||
cls._create_missing_entries_for_broadcast_notifications_with_defaults(
|
||||
user, read=True
|
||||
)
|
||||
|
||||
NotificationRecipient.objects.filter(
|
||||
Q(workspace_id=workspace.pk) | Q(workspace_id=None),
|
||||
recipient=user,
|
||||
read=False,
|
||||
cleared=False,
|
||||
).update(read=True)
|
||||
|
||||
if send_signal:
|
||||
all_notifications_marked_as_read.send(
|
||||
sender=cls, user=user, workspace=workspace
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def construct_notification(
|
||||
cls, notification_type: str, sender=None, data=None, workspace=None, **kwargs
|
||||
) -> Notification:
|
||||
"""
|
||||
Create the notification with the provided data.
|
||||
|
||||
:param notification_type: The type of the notification.
|
||||
:param sender: The user that sent the notification.
|
||||
:param data: The data that will be stored in the notification.
|
||||
:param workspace: The workspace that the notification is linked to.
|
||||
:return: The constructed notification instance. Be aware that this
|
||||
instance is not saved yet.
|
||||
"""
|
||||
|
||||
return Notification(
|
||||
type=notification_type,
|
||||
sender=sender,
|
||||
data=data or {},
|
||||
workspace=workspace,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def create_notification(
|
||||
cls, notification_type: str, sender=None, data=None, workspace=None, **kwargs
|
||||
) -> Notification:
|
||||
"""
|
||||
Create the notification with the provided data.
|
||||
|
||||
:param notification_type: The type of the notification.
|
||||
:param sender: The user that sent the notification.
|
||||
:param data: The data that will be stored in the notification.
|
||||
:param workspace: The workspace that the notification is linked to.
|
||||
:param save: If True the notification will be saved in the database.
|
||||
:return: The created notification instance.
|
||||
"""
|
||||
|
||||
notification = cls.construct_notification(
|
||||
notification_type=notification_type,
|
||||
sender=sender,
|
||||
data=data,
|
||||
workspace=workspace,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
notification.save()
|
||||
|
||||
return notification
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def create_broadcast_notification(
|
||||
cls,
|
||||
notification_type: str,
|
||||
sender=None,
|
||||
data=None,
|
||||
send_signal: bool = True,
|
||||
**kwargs
|
||||
) -> Notification:
|
||||
"""
|
||||
Create the notification with the provided data.
|
||||
|
||||
:param notification_type: The type of the notification.
|
||||
:param sender: The user that sent the notification.
|
||||
:param data: The data that will be stored in the notification.
|
||||
:param workspace: The workspace that the notification is linked to.
|
||||
:param save: If True the notification will be saved in the database.
|
||||
:param send_signal: If True the notification_created signal will be sent.
|
||||
:return: The created notification instance.
|
||||
"""
|
||||
|
||||
notification = cls.create_notification(
|
||||
notification_type=notification_type,
|
||||
sender=sender,
|
||||
data=data,
|
||||
workspace=None,
|
||||
broadcast=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# With recipient=None we create a placeholder recipient that will be
|
||||
# used to send the notification to all users.
|
||||
notification_recipient = NotificationRecipient.objects.create(
|
||||
recipient=None,
|
||||
notification=notification,
|
||||
created_on=notification.created_on,
|
||||
broadcast=notification.broadcast,
|
||||
workspace_id=notification.workspace_id,
|
||||
)
|
||||
|
||||
if send_signal:
|
||||
notification_created.send(
|
||||
sender=cls,
|
||||
notification=notification,
|
||||
notification_recipients=[notification_recipient],
|
||||
user=sender,
|
||||
)
|
||||
|
||||
return notification
|
||||
|
||||
@classmethod
|
||||
@baserow_trace(tracer)
|
||||
def create_notification_for_users(
|
||||
cls,
|
||||
notification_type: str,
|
||||
recipients: List[AbstractUser],
|
||||
sender: Optional[AbstractUser] = None,
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
workspace: Optional[Workspace] = None,
|
||||
send_signal: bool = True,
|
||||
**kwargs
|
||||
) -> List[NotificationRecipient]:
|
||||
"""
|
||||
Creates a notification for each user in the given list with the provided data.
|
||||
|
||||
:param notification_type: The type of the notification.
|
||||
:param recipients: The users that will receive the notification.
|
||||
:param data: The data that will be stored in the notification.
|
||||
:param workspace: The workspace that the notification is linked to.
|
||||
:param send_signal: If True the notification_created signal will be sent.
|
||||
:param kwargs: Any additional kwargs that will be passed to the
|
||||
Notification constructor.
|
||||
:return: A list of the created notification recipients instances.
|
||||
"""
|
||||
|
||||
notification = cls.create_notification(
|
||||
notification_type=notification_type,
|
||||
data=data,
|
||||
sender=sender,
|
||||
broadcast=False,
|
||||
workspace=workspace,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
notification_recipients = NotificationRecipient.objects.bulk_create(
|
||||
[
|
||||
NotificationRecipient(
|
||||
recipient=recipient,
|
||||
notification=notification,
|
||||
broadcast=notification.broadcast,
|
||||
workspace_id=notification.workspace_id,
|
||||
created_on=notification.created_on,
|
||||
)
|
||||
for recipient in recipients
|
||||
]
|
||||
)
|
||||
|
||||
if send_signal:
|
||||
notification_created.send(
|
||||
sender=cls,
|
||||
user=sender,
|
||||
notification=notification,
|
||||
notification_recipients=notification_recipients,
|
||||
)
|
||||
return notification_recipients
|
118
backend/src/baserow/core/notifications/models.py
Normal file
118
backend/src/baserow/core/notifications/models.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.db import models
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
created_on = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="The date and time when the notification was created.",
|
||||
)
|
||||
type = models.CharField(max_length=64, help_text="The type of notification.")
|
||||
broadcast = models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
"If True, then the notification will be sent to all users. "
|
||||
"A broadcast notification will not immediately create all the "
|
||||
"NotificationRecipient objects, but will do so when the "
|
||||
"notification is read or cleared by a user."
|
||||
),
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"core.Workspace",
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notifications",
|
||||
help_text=(
|
||||
"The workspace where the notification lives."
|
||||
"If the notification is a broadcast notification, then the "
|
||||
"workspace will be None."
|
||||
"Workspace can be null also if the notification is not "
|
||||
"associated with a specific workspace or if the user does not "
|
||||
"have access to the workspace yet, like for a workspace invitation."
|
||||
),
|
||||
)
|
||||
sender = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="sent_notifications",
|
||||
help_text="The user that will receive the notification.",
|
||||
)
|
||||
recipients = models.ManyToManyField(
|
||||
User,
|
||||
through="NotificationRecipient",
|
||||
related_name="notifications",
|
||||
help_text=(
|
||||
"The users that will receive the notification. For broadcast "
|
||||
"notifications, the recipients will be created when the "
|
||||
"notification is read or cleared by the user."
|
||||
"For direct notifications, the recipients will be created "
|
||||
"immediately when the notification is created."
|
||||
),
|
||||
)
|
||||
data = models.JSONField(default=dict, help_text="The data of the notification.")
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_on"]
|
||||
indexes = [
|
||||
models.Index(fields=["-created_on"]),
|
||||
GinIndex(name="notification_data", fields=["data"]),
|
||||
]
|
||||
|
||||
|
||||
class NotificationRecipient(models.Model):
|
||||
notification = models.ForeignKey(
|
||||
Notification,
|
||||
on_delete=models.CASCADE,
|
||||
help_text="The notification that will be sent to the recipient.",
|
||||
)
|
||||
recipient = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
help_text="The user that will receive the notification.",
|
||||
)
|
||||
read = models.BooleanField(
|
||||
default=False,
|
||||
help_text=("If True, then the notification has been read by the user. "),
|
||||
)
|
||||
cleared = models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
"If True, then the notification has been cleared by the user. "
|
||||
"Cleared notifications will not be visible by the user anymore."
|
||||
),
|
||||
)
|
||||
# The following fields are copies of the notification fields needed to
|
||||
# speed up queries.
|
||||
created_on = models.DateTimeField(
|
||||
help_text="A copy of the notification created_on field needed to speed up queries.",
|
||||
)
|
||||
broadcast = models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
"A copy of the notification broadcast field needed to speed up queries."
|
||||
),
|
||||
)
|
||||
workspace_id = models.BigIntegerField(
|
||||
null=True,
|
||||
help_text=(
|
||||
"A copy of the notification workspace_id field needed to speed up queries."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_on"]
|
||||
unique_together = ("notification", "recipient")
|
||||
indexes = [
|
||||
models.Index(fields=["-created_on"]),
|
||||
models.Index(
|
||||
fields=["broadcast", "cleared", "read", "recipient_id", "workspace_id"],
|
||||
name="unread_notif_count_idx",
|
||||
condition=models.Q(cleared=False, read=False),
|
||||
include=["notification_id"],
|
||||
),
|
||||
]
|
13
backend/src/baserow/core/notifications/operations.py
Normal file
13
backend/src/baserow/core/notifications/operations.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from baserow.core.operations import WorkspaceCoreOperationType
|
||||
|
||||
|
||||
class ListNotificationsOperationType(WorkspaceCoreOperationType):
|
||||
type = "workspace.list_notifications"
|
||||
|
||||
|
||||
class ClearNotificationsOperationType(WorkspaceCoreOperationType):
|
||||
type = "workspace.clear_notification"
|
||||
|
||||
|
||||
class MarkNotificationAsReadOperationType(WorkspaceCoreOperationType):
|
||||
type = "workspace.mark_notification_as_read"
|
104
backend/src/baserow/core/notifications/receivers.py
Normal file
104
backend/src/baserow/core/notifications/receivers.py
Normal file
|
@ -0,0 +1,104 @@
|
|||
from typing import List
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import transaction
|
||||
from django.dispatch import receiver
|
||||
|
||||
from baserow.api.notifications.serializers import NotificationRecipientSerializer
|
||||
from baserow.core.notifications.models import Notification, NotificationRecipient
|
||||
from baserow.ws.tasks import broadcast_to_users
|
||||
|
||||
from .signals import (
|
||||
all_notifications_cleared,
|
||||
all_notifications_marked_as_read,
|
||||
notification_created,
|
||||
notification_marked_as_read,
|
||||
)
|
||||
|
||||
|
||||
@receiver(notification_created)
|
||||
def notify_notification_created(
|
||||
sender,
|
||||
notification: Notification,
|
||||
notification_recipients: List[NotificationRecipient],
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Sends a notification to the recipient of the notification.
|
||||
"""
|
||||
|
||||
send_to_all_users = notification.broadcast
|
||||
user_ids = [recipient.recipient_id for recipient in notification_recipients]
|
||||
|
||||
# the data are the same for all the recipients, so just pick the first one.
|
||||
notification_data = notification_recipients[:1]
|
||||
|
||||
transaction.on_commit(
|
||||
lambda: broadcast_to_users.delay(
|
||||
user_ids,
|
||||
{
|
||||
"type": "notifications_created",
|
||||
"notifications": NotificationRecipientSerializer(
|
||||
notification_data, many=True
|
||||
).data,
|
||||
},
|
||||
send_to_all_users=send_to_all_users,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@receiver(notification_marked_as_read)
|
||||
def notify_notification_marked_as_read(
|
||||
sender,
|
||||
notification: Notification,
|
||||
notification_recipient: NotificationRecipient,
|
||||
user: AbstractUser,
|
||||
ignore_web_socket_id=None,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Notify the user that the notification has been marked as read.
|
||||
"""
|
||||
|
||||
transaction.on_commit(
|
||||
lambda: broadcast_to_users.delay(
|
||||
[user.id],
|
||||
{
|
||||
"type": "notification_marked_as_read",
|
||||
"notification": NotificationRecipientSerializer(
|
||||
notification_recipient
|
||||
).data,
|
||||
},
|
||||
ignore_web_socket_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@receiver(all_notifications_marked_as_read)
|
||||
def notify_all_notifications_marked_as_read(sender, user: AbstractUser, **kwargs):
|
||||
"""
|
||||
Notify the user that all notifications have been marked as read.
|
||||
"""
|
||||
|
||||
transaction.on_commit(
|
||||
lambda: broadcast_to_users.delay(
|
||||
[user.id],
|
||||
{"type": "all_notifications_marked_as_read"},
|
||||
getattr(user, "web_socket_id", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@receiver(all_notifications_cleared)
|
||||
def notify_all_notifications_cleared(sender, user: AbstractUser, **kwargs):
|
||||
"""
|
||||
Notify the user that all notifications have been cleared.
|
||||
"""
|
||||
|
||||
transaction.on_commit(
|
||||
lambda: broadcast_to_users.delay(
|
||||
[user.id],
|
||||
{"type": "all_notifications_cleared"},
|
||||
getattr(user, "web_socket_id", None),
|
||||
)
|
||||
)
|
43
backend/src/baserow/core/notifications/registries.py
Normal file
43
backend/src/baserow/core/notifications/registries.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
from baserow.core.exceptions import (
|
||||
InstanceTypeAlreadyRegistered,
|
||||
InstanceTypeDoesNotExist,
|
||||
)
|
||||
from baserow.core.registry import (
|
||||
CustomFieldsRegistryMixin,
|
||||
Instance,
|
||||
MapAPIExceptionsInstanceMixin,
|
||||
ModelRegistryMixin,
|
||||
Registry,
|
||||
)
|
||||
|
||||
from .models import Notification
|
||||
|
||||
|
||||
class NotificationType(MapAPIExceptionsInstanceMixin, Instance):
|
||||
model_class = Notification
|
||||
|
||||
|
||||
class NotificationTypeDoesNotExist(InstanceTypeDoesNotExist):
|
||||
"""Raised when a notification type with a given identifier does not exist."""
|
||||
|
||||
|
||||
class NotificationTypeAlreadyRegistered(InstanceTypeAlreadyRegistered):
|
||||
"""Raised when a notification type is already registered."""
|
||||
|
||||
|
||||
class NotificationTypeRegistry(
|
||||
CustomFieldsRegistryMixin,
|
||||
ModelRegistryMixin[Notification, NotificationType],
|
||||
Registry[NotificationType],
|
||||
):
|
||||
"""
|
||||
The registry that holds all the available job types.
|
||||
"""
|
||||
|
||||
name = "notification_type"
|
||||
|
||||
does_not_exist_exception_class = NotificationTypeDoesNotExist
|
||||
already_registered_exception_class = NotificationTypeAlreadyRegistered
|
||||
|
||||
|
||||
notification_type_registry: NotificationTypeRegistry = NotificationTypeRegistry()
|
89
backend/src/baserow/core/notifications/service.py
Normal file
89
backend/src/baserow/core/notifications/service.py
Normal file
|
@ -0,0 +1,89 @@
|
|||
from typing import Type
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
|
||||
from baserow.core.handler import CoreHandler
|
||||
from baserow.core.notifications.handler import NotificationHandler
|
||||
from baserow.core.notifications.models import NotificationRecipient
|
||||
from baserow.core.registries import OperationType
|
||||
|
||||
from .operations import (
|
||||
ListNotificationsOperationType,
|
||||
MarkNotificationAsReadOperationType,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class NotificationService:
|
||||
@classmethod
|
||||
def get_workspace_if_has_permissions_or_raise(
|
||||
cls, user: AbstractUser, workspace_id: int, permission_type: Type[OperationType]
|
||||
):
|
||||
workspace = CoreHandler().get_workspace(workspace_id)
|
||||
|
||||
CoreHandler().check_permissions(
|
||||
user, permission_type.type, workspace=workspace, context=workspace
|
||||
)
|
||||
return workspace
|
||||
|
||||
@classmethod
|
||||
def list_notifications(cls, user, workspace_id: int):
|
||||
workspace = cls.get_workspace_if_has_permissions_or_raise(
|
||||
user, workspace_id, ListNotificationsOperationType
|
||||
)
|
||||
|
||||
return NotificationHandler.list_notifications(user, workspace)
|
||||
|
||||
@classmethod
|
||||
def mark_notification_as_read(
|
||||
cls,
|
||||
user: AbstractUser,
|
||||
workspace_id: int,
|
||||
notification_id: int,
|
||||
read: bool = True,
|
||||
) -> NotificationRecipient:
|
||||
cls.get_workspace_if_has_permissions_or_raise(
|
||||
user, workspace_id, MarkNotificationAsReadOperationType
|
||||
)
|
||||
|
||||
notification = NotificationHandler.get_notification_by_id(user, notification_id)
|
||||
return NotificationHandler.mark_notification_as_read(user, notification, read)
|
||||
|
||||
@classmethod
|
||||
def mark_all_notifications_as_read(cls, user: AbstractUser, workspace_id: int):
|
||||
"""
|
||||
Marks all notifications as read for the given user and workspace and
|
||||
sends the all_notifications_marked_as_read signal.
|
||||
|
||||
:param user: The user for which to mark all notifications as read.
|
||||
:param workspace_id: The workspace id for which to mark all
|
||||
notifications as read.
|
||||
"""
|
||||
|
||||
workspace = cls.get_workspace_if_has_permissions_or_raise(
|
||||
user, workspace_id, MarkNotificationAsReadOperationType
|
||||
)
|
||||
|
||||
NotificationHandler.mark_all_notifications_as_read(user, workspace)
|
||||
|
||||
@classmethod
|
||||
def clear_all_notifications(
|
||||
cls,
|
||||
user: AbstractUser,
|
||||
workspace_id: int,
|
||||
):
|
||||
"""
|
||||
Clears all notifications for the given user and workspace and sends the
|
||||
all_notifications_cleared signal.
|
||||
|
||||
:param user: The user for which to clear all notifications.
|
||||
:param workspace_id: The workspace id for which to clear all
|
||||
"""
|
||||
|
||||
workspace = cls.get_workspace_if_has_permissions_or_raise(
|
||||
user, workspace_id, MarkNotificationAsReadOperationType
|
||||
)
|
||||
|
||||
NotificationHandler.clear_all_notifications(user, workspace)
|
6
backend/src/baserow/core/notifications/signals.py
Normal file
6
backend/src/baserow/core/notifications/signals.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.dispatch import Signal
|
||||
|
||||
notification_created = Signal()
|
||||
notification_marked_as_read = Signal()
|
||||
all_notifications_marked_as_read = Signal()
|
||||
all_notifications_cleared = Signal()
|
|
@ -1,7 +1,14 @@
|
|||
from typing import List
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from baserow.core.handler import CoreHandler
|
||||
from baserow.core.models import WorkspaceUser
|
||||
from baserow.core.notifications.operations import (
|
||||
ClearNotificationsOperationType,
|
||||
ListNotificationsOperationType,
|
||||
MarkNotificationAsReadOperationType,
|
||||
)
|
||||
|
||||
from .exceptions import (
|
||||
IsNotAdminError,
|
||||
|
@ -90,6 +97,11 @@ class WorkspaceMemberOnlyPermissionManagerType(PermissionManagerType):
|
|||
|
||||
type = "member"
|
||||
supported_actor_types = [UserSubjectType.type]
|
||||
ALWAYS_ALLOWED_OPERATIONS: List[str] = [
|
||||
ClearNotificationsOperationType.type,
|
||||
ListNotificationsOperationType.type,
|
||||
MarkNotificationAsReadOperationType.type,
|
||||
]
|
||||
|
||||
def check_multiple_permissions(self, checks, workspace=None, include_trash=False):
|
||||
if workspace is None:
|
||||
|
@ -107,6 +119,8 @@ class WorkspaceMemberOnlyPermissionManagerType(PermissionManagerType):
|
|||
for check in checks:
|
||||
if check.actor.id not in user_ids_in_workspace:
|
||||
permission_by_check[check] = UserNotInWorkspace(check.actor, workspace)
|
||||
elif check.operation_name in self.ALWAYS_ALLOWED_OPERATIONS:
|
||||
permission_by_check[check] = True
|
||||
|
||||
return permission_by_check
|
||||
|
||||
|
|
|
@ -60,10 +60,9 @@ def extract_mentioned_users_in_workspace(
|
|||
"""
|
||||
|
||||
mentioned_user_ids = extract_mentioned_user_ids(json_doc)
|
||||
qs = workspace.users.filter(id__in=mentioned_user_ids, profile__to_be_deleted=False)
|
||||
if qs.count() != len(mentioned_user_ids):
|
||||
raise ValueError("Cannot mention users that are not in the workspace.")
|
||||
return qs
|
||||
return workspace.users.filter(
|
||||
id__in=mentioned_user_ids, profile__to_be_deleted=False
|
||||
)
|
||||
|
||||
|
||||
def prosemirror_doc_from_plain_text(plain_text_message) -> Dict[str, Any]:
|
||||
|
|
|
@ -27,3 +27,7 @@ application_deleted = Signal()
|
|||
applications_reordered = Signal()
|
||||
|
||||
permissions_updated = Signal()
|
||||
|
||||
workspace_invitation_created = Signal()
|
||||
workspace_invitation_accepted = Signal()
|
||||
workspace_invitation_rejected = Signal()
|
||||
|
|
|
@ -8,6 +8,7 @@ from .field import FieldFixtures
|
|||
from .file_import import FileImportFixtures
|
||||
from .integration import IntegrationFixtures
|
||||
from .job import JobFixtures
|
||||
from .notifications import NotificationsFixture
|
||||
from .page import PageFixtures
|
||||
from .row import RowFixture
|
||||
from .service import ServiceFixtures
|
||||
|
@ -47,6 +48,7 @@ class Fixtures(
|
|||
IntegrationFixtures,
|
||||
ServiceFixtures,
|
||||
DataSourceFixtures,
|
||||
NotificationsFixture,
|
||||
):
|
||||
def __init__(self, fake=None):
|
||||
self.fake = fake
|
||||
|
|
46
backend/src/baserow/test_utils/fixtures/notifications.py
Normal file
46
backend/src/baserow/test_utils/fixtures/notifications.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
from baserow.core.notifications.handler import NotificationHandler
|
||||
|
||||
|
||||
class NotificationsFixture:
|
||||
def create_notification(self, recipients=None, **kwargs):
|
||||
if recipients is None:
|
||||
recipients = [self.create_user()]
|
||||
|
||||
notification_type = kwargs.pop("type", "fake_notification")
|
||||
|
||||
notification_recipients = NotificationHandler.create_notification_for_users(
|
||||
notification_type, recipients=recipients, **kwargs
|
||||
)
|
||||
|
||||
return notification_recipients[0].notification
|
||||
|
||||
def create_workspace_notification(self, recipients=None, workspace=None, **kwargs):
|
||||
if recipients is None:
|
||||
recipients = [self.create_user()]
|
||||
|
||||
if workspace is None:
|
||||
workspace = self.create_workspace(members=recipients)
|
||||
|
||||
if "type" not in kwargs:
|
||||
kwargs["type"] = "fake_workspace_notification"
|
||||
|
||||
return self.create_notification(
|
||||
recipients=recipients, workspace=workspace, **kwargs
|
||||
)
|
||||
|
||||
def create_user_notification(self, recipients=None, **kwargs):
|
||||
if "type" not in kwargs:
|
||||
kwargs["type"] = "fake_user_notification"
|
||||
|
||||
return self.create_notification(recipients=recipients, **kwargs)
|
||||
|
||||
def create_broadcast_notification(self, **kwargs):
|
||||
notification_type = kwargs.pop("type", "fake_broadcast_notification")
|
||||
|
||||
if "workspace_id" in kwargs:
|
||||
kwargs["workspace_id"] = None
|
||||
|
||||
notification = NotificationHandler.create_broadcast_notification(
|
||||
notification_type, workspace_id=None, **kwargs
|
||||
)
|
||||
return notification
|
|
@ -3,6 +3,7 @@ from django.contrib.auth import get_user_model
|
|||
from rest_framework_simplejwt.tokens import AccessToken, RefreshToken
|
||||
|
||||
from baserow.api.sessions import (
|
||||
_set_user_websocket_id,
|
||||
set_client_undo_redo_action_group_id,
|
||||
set_untrusted_client_session_id,
|
||||
)
|
||||
|
@ -39,6 +40,7 @@ class UserFixtures:
|
|||
|
||||
session_id = kwargs.pop("session_id", "default-test-user-session-id")
|
||||
action_group = kwargs.pop("action_group", None)
|
||||
web_socket_id = kwargs.pop("web_socket_id", None)
|
||||
|
||||
profile_data["language"] = kwargs.pop("language", "en")
|
||||
profile_data["to_be_deleted"] = kwargs.pop("to_be_deleted", False)
|
||||
|
@ -54,6 +56,7 @@ class UserFixtures:
|
|||
|
||||
set_untrusted_client_session_id(user, session_id)
|
||||
set_client_undo_redo_action_group_id(user, action_group)
|
||||
_set_user_websocket_id(user, web_socket_id)
|
||||
|
||||
# add it to a specific workspace if it is given
|
||||
if "workspace" in kwargs:
|
||||
|
|
|
@ -7,6 +7,9 @@ from baserow.api.applications.serializers import (
|
|||
get_application_serializer,
|
||||
)
|
||||
from baserow.api.user.serializers import PublicUserSerializer
|
||||
from baserow.api.workspaces.invitations.serializers import (
|
||||
UserWorkspaceInvitationSerializer,
|
||||
)
|
||||
from baserow.api.workspaces.serializers import (
|
||||
WorkspaceSerializer,
|
||||
WorkspaceUserSerializer,
|
||||
|
@ -321,3 +324,50 @@ def applications_reordered(sender, workspace, order, user, **kwargs):
|
|||
getattr(user, "web_socket_id", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@receiver(signals.workspace_invitation_created)
|
||||
def notify_workspace_invitation_created(
|
||||
sender, invitation, invited_user=None, **kwargs
|
||||
):
|
||||
if invited_user is not None:
|
||||
serialized_data = UserWorkspaceInvitationSerializer(invitation).data
|
||||
transaction.on_commit(
|
||||
lambda: broadcast_to_users.delay(
|
||||
[invited_user.id],
|
||||
{
|
||||
"type": "workspace_invitation_created",
|
||||
"invitation": serialized_data,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@receiver(signals.workspace_invitation_accepted)
|
||||
def notify_workspace_invitation_accepted(sender, invitation, user, **kwargs):
|
||||
# invitation will be deleted on commit, so serialize it now to have the id
|
||||
serialized_data = UserWorkspaceInvitationSerializer(invitation).data
|
||||
transaction.on_commit(
|
||||
lambda: broadcast_to_users.delay(
|
||||
[user.id],
|
||||
{
|
||||
"type": "workspace_invitation_accepted",
|
||||
"invitation": serialized_data,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@receiver(signals.workspace_invitation_rejected)
|
||||
def notify_workspace_invitation_rejected(sender, invitation, user, **kwargs):
|
||||
# invitation will be deleted on commit, so serialize it now to have the id
|
||||
serialized_data = UserWorkspaceInvitationSerializer(invitation).data
|
||||
transaction.on_commit(
|
||||
lambda: broadcast_to_users.delay(
|
||||
[user.id],
|
||||
{
|
||||
"type": "workspace_invitation_rejected",
|
||||
"invitation": serialized_data,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
|
@ -35,6 +35,7 @@ def test_list_workspaces(api_client, data_fixture):
|
|||
assert response_json[1]["order"] == 2
|
||||
assert response_json[1]["name"] == user_workspace_2.workspace.name
|
||||
assert response_json[1]["permissions"] == "ADMIN"
|
||||
assert response_json[0]["unread_notifications_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
|
@ -0,0 +1,420 @@
|
|||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
HTTP_204_NO_CONTENT,
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_404_NOT_FOUND,
|
||||
)
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from baserow.core.notifications.handler import NotificationHandler
|
||||
from baserow.core.notifications.models import Notification, NotificationRecipient
|
||||
from baserow.test_utils.helpers import AnyInt
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_outside_workspace_cannot_see_notifications(data_fixture, api_client):
|
||||
_, token = data_fixture.create_user_and_token()
|
||||
workspace = data_fixture.create_workspace()
|
||||
|
||||
response = api_client.get(
|
||||
reverse("api:notifications:list", kwargs={"workspace_id": workspace.id}),
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_cannot_see_notifications_of_non_existing_workspace(
|
||||
data_fixture, api_client
|
||||
):
|
||||
_, token = data_fixture.create_user_and_token()
|
||||
|
||||
response = api_client.get(
|
||||
reverse("api:notifications:list", kwargs={"workspace_id": 999}),
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_GROUP_DOES_NOT_EXIST"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_can_see_notifications_paginated(data_fixture, api_client):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
|
||||
notifications_count = settings.ROW_PAGE_SIZE_LIMIT + 2
|
||||
|
||||
for _ in range(notifications_count):
|
||||
data_fixture.create_workspace_notification(
|
||||
workspace=workspace, recipients=[user], type="test"
|
||||
)
|
||||
|
||||
# Offset 0 and limit as by default.
|
||||
response = api_client.get(
|
||||
reverse("api:notifications:list", kwargs={"workspace_id": workspace.id}),
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == notifications_count
|
||||
assert len(response.json()["results"]) == settings.ROW_PAGE_SIZE_LIMIT
|
||||
|
||||
# Offset a page and limit=1.
|
||||
response = api_client.get(
|
||||
reverse("api:notifications:list", kwargs={"workspace_id": workspace.id})
|
||||
+ f"?offset={settings.ROW_PAGE_SIZE_LIMIT}&limit=1",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == notifications_count
|
||||
assert len(response.json()["results"]) == 1
|
||||
|
||||
# Offset a page and limit as by default.
|
||||
response = api_client.get(
|
||||
reverse("api:notifications:list", kwargs={"workspace_id": workspace.id})
|
||||
+ f"?offset={settings.ROW_PAGE_SIZE_LIMIT}",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == notifications_count
|
||||
assert len(response.json()["results"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_get_unread_workspace_count_listing_workspaces(data_fixture, api_client):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
|
||||
with freeze_time("2021-01-01 12:00"):
|
||||
data_fixture.create_workspace_notification(
|
||||
workspace=workspace, recipients=[user]
|
||||
)
|
||||
|
||||
response = api_client.get(
|
||||
reverse("api:workspaces:list"),
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()[0]["unread_notifications_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_get_unread_user_count_refreshing_token(data_fixture, api_client):
|
||||
data_fixture.create_password_provider()
|
||||
user = data_fixture.create_user(email="test@test.nl", password="password")
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:user:token_auth"),
|
||||
{"email": "test@test.nl", "password": "password"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["user_notifications"]["unread_count"] == 0
|
||||
|
||||
refresh_token = str(RefreshToken.for_user(user))
|
||||
response = api_client.post(
|
||||
reverse("api:user:token_refresh"),
|
||||
{"refresh_token": refresh_token},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["user_notifications"]["unread_count"] == 0
|
||||
|
||||
data_fixture.create_user_notification(recipients=[user])
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:user:token_auth"),
|
||||
{"email": "test@test.nl", "password": "password"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["user_notifications"]["unread_count"] == 1
|
||||
|
||||
refresh_token = str(RefreshToken.for_user(user))
|
||||
response = api_client.post(
|
||||
reverse("api:user:token_refresh"),
|
||||
{"refresh_token": refresh_token},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["user_notifications"]["unread_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_fetch_workspace_and_user_notifications_together(data_fixture, api_client):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
|
||||
with freeze_time("2021-01-01 12:00"):
|
||||
data_fixture.create_workspace_notification(
|
||||
sender=user,
|
||||
workspace=workspace,
|
||||
recipients=[user],
|
||||
type="fake_application_notification",
|
||||
)
|
||||
with freeze_time("2021-01-01 12:01"):
|
||||
data_fixture.create_user_notification(
|
||||
recipients=[user],
|
||||
type="version_update",
|
||||
data={"version": "1.0.0"},
|
||||
)
|
||||
|
||||
response = api_client.get(
|
||||
reverse("api:notifications:list", kwargs={"workspace_id": workspace.id}),
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["count"] == 2
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": AnyInt(),
|
||||
"sender": None,
|
||||
"created_on": "2021-01-01T12:01:00Z",
|
||||
"read": False,
|
||||
"type": "version_update",
|
||||
"data": {"version": "1.0.0"},
|
||||
"workspace": None,
|
||||
},
|
||||
{
|
||||
"id": AnyInt(),
|
||||
"sender": {
|
||||
"id": user.id,
|
||||
"username": user.email,
|
||||
"first_name": user.first_name,
|
||||
},
|
||||
"created_on": "2021-01-01T12:00:00Z",
|
||||
"read": False,
|
||||
"type": "fake_application_notification",
|
||||
"data": {},
|
||||
"workspace": {"id": workspace.id},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_can_mark_notifications_as_read(data_fixture, api_client):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
|
||||
notification = data_fixture.create_workspace_notification(
|
||||
workspace=workspace, recipients=[user]
|
||||
)
|
||||
recipient = NotificationRecipient.objects.get(
|
||||
notification=notification, recipient=user
|
||||
)
|
||||
assert recipient.read is False
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
"api:notifications:item",
|
||||
kwargs={"workspace_id": workspace.id, "notification_id": notification.id},
|
||||
),
|
||||
{"read": True},
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json()["read"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_cannot_mark_notifications_as_read_if_not_part_of_workspace(
|
||||
data_fixture, api_client
|
||||
):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
workspace = data_fixture.create_workspace()
|
||||
|
||||
notification = data_fixture.create_workspace_notification(
|
||||
workspace=workspace, recipients=[user]
|
||||
)
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
"api:notifications:item",
|
||||
kwargs={"workspace_id": workspace.id, "notification_id": notification.id},
|
||||
),
|
||||
{"read": True},
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_cannot_mark_notifications_as_read_if_notification_does_not_exists(
|
||||
data_fixture, api_client
|
||||
):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
"api:notifications:item",
|
||||
kwargs={"workspace_id": workspace.id, "notification_id": 999},
|
||||
),
|
||||
{"read": True},
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_NOTIFICATION_DOES_NOT_EXIST"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_can_mark_all_own_notifications_as_read(data_fixture, api_client):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
other_user = data_fixture.create_user()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
other_workspace = data_fixture.create_workspace(user=other_user)
|
||||
|
||||
data_fixture.create_workspace_notification(workspace=workspace, recipients=[user])
|
||||
data_fixture.create_user_notification(recipients=[user])
|
||||
|
||||
# other workspace
|
||||
data_fixture.create_workspace_notification(
|
||||
workspace=other_workspace, recipients=[other_user]
|
||||
)
|
||||
|
||||
data_fixture.create_broadcast_notification()
|
||||
|
||||
def user_unread_count():
|
||||
return NotificationHandler.get_unread_notifications_count(user, workspace)
|
||||
|
||||
def other_user_unread_count():
|
||||
return NotificationHandler.get_unread_notifications_count(
|
||||
other_user, other_workspace
|
||||
)
|
||||
|
||||
assert user_unread_count() == 3
|
||||
assert other_user_unread_count() == 2
|
||||
|
||||
response = api_client.post(
|
||||
reverse(
|
||||
"api:notifications:mark_all_as_read",
|
||||
kwargs={"workspace_id": workspace.id},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_204_NO_CONTENT
|
||||
# Only notifications for the user should be marked as read
|
||||
assert user_unread_count() == 0
|
||||
assert other_user_unread_count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_cannot_mark_all_notifications_as_read_if_not_part_of_workspace(
|
||||
data_fixture, api_client
|
||||
):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
workspace = data_fixture.create_workspace()
|
||||
|
||||
data_fixture.create_workspace_notification(workspace=workspace, recipients=[user])
|
||||
data_fixture.create_user_notification(recipients=[user])
|
||||
|
||||
response = api_client.post(
|
||||
reverse(
|
||||
"api:notifications:mark_all_as_read",
|
||||
kwargs={"workspace_id": workspace.id},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_cannot_mark_all_notifications_as_read_if_workspace_does_not_exist(
|
||||
data_fixture, api_client
|
||||
):
|
||||
_, token = data_fixture.create_user_and_token()
|
||||
|
||||
response = api_client.post(
|
||||
reverse(
|
||||
"api:notifications:mark_all_as_read",
|
||||
kwargs={"workspace_id": 999},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_GROUP_DOES_NOT_EXIST"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_cannot_clear_all_notifications_if_not_part_of_workspace(
|
||||
data_fixture, api_client
|
||||
):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
workspace = data_fixture.create_workspace()
|
||||
|
||||
data_fixture.create_workspace_notification(workspace=workspace, recipients=[user])
|
||||
data_fixture.create_user_notification(recipients=[user])
|
||||
|
||||
response = api_client.delete(
|
||||
reverse(
|
||||
"api:notifications:list",
|
||||
kwargs={"workspace_id": workspace.id},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_cannot_clear_all_notifications_if_workspace_does_not_exist(
|
||||
data_fixture, api_client
|
||||
):
|
||||
_, token = data_fixture.create_user_and_token()
|
||||
|
||||
response = api_client.delete(
|
||||
reverse(
|
||||
"api:notifications:list",
|
||||
kwargs={"workspace_id": 999},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_can_clear_all_own_notifications(data_fixture, api_client):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
user_2 = data_fixture.create_user()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
workspace_2 = data_fixture.create_workspace(user=user_2)
|
||||
|
||||
data_fixture.create_workspace_notification(recipients=[user], workspace=workspace)
|
||||
data_fixture.create_user_notification(recipients=[user])
|
||||
|
||||
# other workspace and user
|
||||
data_fixture.create_workspace_notification(
|
||||
workspace=workspace_2, recipients=[user_2]
|
||||
)
|
||||
data_fixture.create_user_notification(recipients=[user_2])
|
||||
|
||||
assert Notification.objects.count() == 4
|
||||
|
||||
response = api_client.delete(
|
||||
reverse(
|
||||
"api:notifications:list",
|
||||
kwargs={"workspace_id": workspace.id},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_204_NO_CONTENT
|
||||
assert Notification.objects.count() == 2
|
|
@ -0,0 +1,300 @@
|
|||
from unittest.mock import call, patch
|
||||
|
||||
from django.shortcuts import reverse
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT
|
||||
|
||||
from baserow.core.notification_types import (
|
||||
BaserowVersionUpgradeNotificationType,
|
||||
WorkspaceInvitationAcceptedNotificationType,
|
||||
WorkspaceInvitationCreatedNotificationType,
|
||||
WorkspaceInvitationRejectedNotificationType,
|
||||
)
|
||||
from baserow.core.notifications.handler import NotificationHandler
|
||||
from baserow.core.notifications.models import NotificationRecipient
|
||||
from baserow.test_utils.helpers import AnyInt
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.core.notifications.signals.notification_created.send")
|
||||
def test_notification_creation_on_creating_group_invitation(
|
||||
mocked_notification_created, api_client, data_fixture
|
||||
):
|
||||
user_1, token_1 = data_fixture.create_user_and_token(email="test1@test.nl")
|
||||
user_2, token_2 = data_fixture.create_user_and_token(email="test2@test.nl")
|
||||
workspace_1 = data_fixture.create_workspace(user=user_1)
|
||||
workspace_2 = data_fixture.create_workspace(user=user_2)
|
||||
|
||||
with freeze_time("2023-07-06 12:00"):
|
||||
response = api_client.post(
|
||||
reverse(
|
||||
"api:workspaces:invitations:list",
|
||||
kwargs={"workspace_id": workspace_1.id},
|
||||
),
|
||||
{
|
||||
"email": "test2@test.nl",
|
||||
"permissions": "ADMIN",
|
||||
"base_url": "http://localhost:3000/invite",
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token_1}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
invitation_id = response_json["id"]
|
||||
|
||||
assert mocked_notification_created.called_once()
|
||||
args = mocked_notification_created.call_args
|
||||
assert args == call(
|
||||
sender=NotificationHandler,
|
||||
notification=NotificationHandler.get_notification_by(
|
||||
user_2, data__contains={"invitation_id": invitation_id}
|
||||
),
|
||||
notification_recipients=list(
|
||||
NotificationRecipient.objects.filter(recipient=user_2)
|
||||
),
|
||||
user=user_1,
|
||||
)
|
||||
|
||||
# the user can see the notification in the list of notifications
|
||||
response = api_client.get(
|
||||
reverse("api:notifications:list", kwargs={"workspace_id": workspace_2.id}),
|
||||
HTTP_AUTHORIZATION=f"JWT {token_2}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
assert response_json == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"id": AnyInt(),
|
||||
"created_on": "2023-07-06T12:00:00Z",
|
||||
"type": WorkspaceInvitationCreatedNotificationType.type,
|
||||
"read": False,
|
||||
"sender": {
|
||||
"id": user_1.id,
|
||||
"username": user_1.username,
|
||||
"first_name": user_1.first_name,
|
||||
},
|
||||
"workspace": None,
|
||||
"data": {
|
||||
"invitation_id": invitation_id,
|
||||
"invited_to_workspace_id": workspace_1.id,
|
||||
"invited_to_workspace_name": workspace_1.name,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.core.notifications.signals.notification_created.send")
|
||||
def test_notification_creation_on_accepting_group_invitation(
|
||||
mocked_notification_created, api_client, data_fixture
|
||||
):
|
||||
user_1, token_1 = data_fixture.create_user_and_token(email="test1@test.nl")
|
||||
user_2, token_2 = data_fixture.create_user_and_token(email="test2@test.nl")
|
||||
workspace_1 = data_fixture.create_workspace(user=user_1)
|
||||
invitation = data_fixture.create_workspace_invitation(
|
||||
invited_by=user_1,
|
||||
workspace=workspace_1,
|
||||
email=user_2.email,
|
||||
permissions="ADMIN",
|
||||
)
|
||||
|
||||
with freeze_time("2023-07-06 12:00"):
|
||||
response = api_client.post(
|
||||
reverse(
|
||||
"api:workspaces:invitations:accept",
|
||||
kwargs={"workspace_invitation_id": invitation.id},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"JWT {token_2}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
assert mocked_notification_created.called_once()
|
||||
args = mocked_notification_created.call_args
|
||||
assert args == call(
|
||||
sender=NotificationHandler,
|
||||
notification=NotificationHandler.get_notification_by(
|
||||
user_1, data__contains={"invitation_id": invitation.id}
|
||||
),
|
||||
notification_recipients=list(
|
||||
NotificationRecipient.objects.filter(recipient=user_1)
|
||||
),
|
||||
user=user_2,
|
||||
)
|
||||
|
||||
# the user can see the notification in the list of notifications
|
||||
response = api_client.get(
|
||||
reverse("api:notifications:list", kwargs={"workspace_id": workspace_1.id}),
|
||||
HTTP_AUTHORIZATION=f"JWT {token_1}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
assert response_json == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"id": AnyInt(),
|
||||
"created_on": "2023-07-06T12:00:00Z",
|
||||
"type": WorkspaceInvitationAcceptedNotificationType.type,
|
||||
"read": False,
|
||||
"sender": {
|
||||
"id": user_2.id,
|
||||
"username": user_2.username,
|
||||
"first_name": user_2.first_name,
|
||||
},
|
||||
"workspace": {"id": workspace_1.id},
|
||||
"data": {
|
||||
"invitation_id": invitation.id,
|
||||
"invited_to_workspace_id": workspace_1.id,
|
||||
"invited_to_workspace_name": workspace_1.name,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.core.notifications.signals.notification_created.send")
|
||||
def test_notification_creation_on_rejecting_group_invitation(
|
||||
mocked_notification_created, api_client, data_fixture
|
||||
):
|
||||
user_1, token_1 = data_fixture.create_user_and_token(email="test1@test.nl")
|
||||
user_2, token_2 = data_fixture.create_user_and_token(email="test2@test.nl")
|
||||
workspace_1 = data_fixture.create_workspace(user=user_1)
|
||||
invitation = data_fixture.create_workspace_invitation(
|
||||
invited_by=user_1,
|
||||
workspace=workspace_1,
|
||||
email=user_2.email,
|
||||
permissions="ADMIN",
|
||||
)
|
||||
|
||||
with freeze_time("2023-07-06 12:00"):
|
||||
response = api_client.post(
|
||||
reverse(
|
||||
"api:workspaces:invitations:reject",
|
||||
kwargs={"workspace_invitation_id": invitation.id},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"JWT {token_2}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_204_NO_CONTENT
|
||||
|
||||
assert mocked_notification_created.called_once()
|
||||
args = mocked_notification_created.call_args
|
||||
assert args == call(
|
||||
sender=NotificationHandler,
|
||||
notification=NotificationHandler.get_notification_by(
|
||||
user_1,
|
||||
data__contains={"invitation_id": invitation.id},
|
||||
),
|
||||
notification_recipients=list(
|
||||
NotificationRecipient.objects.filter(recipient=user_1)
|
||||
),
|
||||
user=user_2,
|
||||
)
|
||||
|
||||
# the user can see the notification in the list of notifications
|
||||
response = api_client.get(
|
||||
reverse("api:notifications:list", kwargs={"workspace_id": workspace_1.id}),
|
||||
HTTP_AUTHORIZATION=f"JWT {token_1}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
assert response_json == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"id": AnyInt(),
|
||||
"created_on": "2023-07-06T12:00:00Z",
|
||||
"type": WorkspaceInvitationRejectedNotificationType.type,
|
||||
"read": False,
|
||||
"sender": {
|
||||
"id": user_2.id,
|
||||
"username": user_2.username,
|
||||
"first_name": user_2.first_name,
|
||||
},
|
||||
"workspace": {"id": workspace_1.id},
|
||||
"data": {
|
||||
"invitation_id": invitation.id,
|
||||
"invited_to_workspace_id": workspace_1.id,
|
||||
"invited_to_workspace_name": workspace_1.name,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.core.notifications.signals.notification_created.send")
|
||||
def test_baserow_version_upgrade_is_sent_as_broadcast_notification(
|
||||
mocked_notification_created, api_client, data_fixture
|
||||
):
|
||||
user_1, token_1 = data_fixture.create_user_and_token()
|
||||
user_2, token_2 = data_fixture.create_user_and_token()
|
||||
workspace_1 = data_fixture.create_workspace(user=user_1)
|
||||
workspace_2 = data_fixture.create_workspace(user=user_2)
|
||||
|
||||
with freeze_time("2023-07-06 12:00"):
|
||||
BaserowVersionUpgradeNotificationType.create_version_upgrade_broadcast_notification(
|
||||
"1.19", "/blog/release-notes/1.19"
|
||||
)
|
||||
|
||||
assert mocked_notification_created.called_once()
|
||||
args = mocked_notification_created.call_args
|
||||
notification = NotificationHandler.get_notification_by(user_1, broadcast=True)
|
||||
assert args == call(
|
||||
sender=NotificationHandler,
|
||||
notification=notification,
|
||||
notification_recipients=list(
|
||||
NotificationRecipient.objects.filter(
|
||||
recipient=None, notification=notification
|
||||
)
|
||||
),
|
||||
user=None,
|
||||
)
|
||||
|
||||
excepted_response = {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"id": AnyInt(),
|
||||
"created_on": "2023-07-06T12:00:00Z",
|
||||
"type": BaserowVersionUpgradeNotificationType.type,
|
||||
"data": {
|
||||
"version": "1.19",
|
||||
"release_notes_url": "/blog/release-notes/1.19",
|
||||
},
|
||||
"read": False,
|
||||
"sender": None,
|
||||
"workspace": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
response = api_client.get(
|
||||
reverse("api:notifications:list", kwargs={"workspace_id": workspace_1.id}),
|
||||
HTTP_AUTHORIZATION=f"JWT {token_1}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == excepted_response
|
||||
|
||||
response = api_client.get(
|
||||
reverse("api:notifications:list", kwargs={"workspace_id": workspace_2.id}),
|
||||
HTTP_AUTHORIZATION=f"JWT {token_2}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == excepted_response
|
|
@ -0,0 +1,220 @@
|
|||
import pytest
|
||||
|
||||
from baserow.core.models import WorkspaceUser
|
||||
from baserow.core.notifications.exceptions import NotificationDoesNotExist
|
||||
from baserow.core.notifications.handler import NotificationHandler
|
||||
from baserow.core.notifications.models import Notification, NotificationRecipient
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_workspace_notifications(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
|
||||
workspace_notification = data_fixture.create_workspace_notification(
|
||||
recipients=[user], workspace=workspace, type="test_workspace_notification"
|
||||
)
|
||||
user_notification = data_fixture.create_user_notification(
|
||||
recipients=[user], type="test_user_notification"
|
||||
)
|
||||
|
||||
notification_recipients = NotificationHandler.list_notifications(
|
||||
user=user, workspace=workspace
|
||||
)
|
||||
|
||||
assert len(notification_recipients) == 2
|
||||
assert notification_recipients[0].notification_id == user_notification.id
|
||||
assert notification_recipients[0].notification.type == "test_user_notification"
|
||||
assert notification_recipients[1].notification_id == workspace_notification.id
|
||||
assert notification_recipients[1].notification.type == "test_workspace_notification"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_notification_by_id(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
notification = data_fixture.create_user_notification(recipients=[user])
|
||||
|
||||
assert (
|
||||
NotificationHandler.get_notification_by_id(user, notification.id).id
|
||||
== notification.id
|
||||
)
|
||||
|
||||
with pytest.raises(NotificationDoesNotExist):
|
||||
NotificationHandler.get_notification_by_id(user, 999)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_annotate_workspaces_with_unread_notifications_count(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
|
||||
q = WorkspaceUser.objects.filter(user=user, workspace=workspace).all()
|
||||
annotated_q = (
|
||||
NotificationHandler.annotate_workspaces_with_unread_notifications_count(
|
||||
user, q, outer_ref_key="workspace_id"
|
||||
)
|
||||
)
|
||||
|
||||
assert annotated_q[0].unread_notifications_count == 0
|
||||
|
||||
data_fixture.create_workspace_notification(recipients=[user], workspace=workspace)
|
||||
|
||||
annotated_q = (
|
||||
NotificationHandler.annotate_workspaces_with_unread_notifications_count(
|
||||
user, q, outer_ref_key="workspace_id"
|
||||
)
|
||||
)
|
||||
|
||||
assert annotated_q[0].unread_notifications_count == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_unread_notifications_count(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
|
||||
assert NotificationHandler.get_unread_notifications_count(user) == 0
|
||||
|
||||
data_fixture.create_user_notification(recipients=[user])
|
||||
|
||||
assert NotificationHandler.get_unread_notifications_count(user) == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mark_notification_as_read(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
|
||||
notification = data_fixture.create_workspace_notification(
|
||||
recipients=[user], workspace=workspace
|
||||
)
|
||||
|
||||
qs = NotificationRecipient.objects.filter(recipient=user, notification=notification)
|
||||
assert qs.get().read is False
|
||||
|
||||
NotificationHandler.mark_notification_as_read(user, notification)
|
||||
|
||||
assert qs.get().read is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mark_all_notifications_as_read(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
|
||||
data_fixture.create_workspace_notification(recipients=[user], workspace=workspace)
|
||||
data_fixture.create_user_notification(recipients=[user])
|
||||
|
||||
assert NotificationHandler.get_unread_notifications_count(user, workspace) == 2
|
||||
|
||||
NotificationHandler.mark_all_notifications_as_read(user, workspace=workspace)
|
||||
|
||||
assert NotificationHandler.get_unread_notifications_count(user, workspace) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_clear_all_direct_notifications(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
|
||||
data_fixture.create_workspace_notification(recipients=[user], workspace=workspace)
|
||||
data_fixture.create_user_notification(recipients=[user])
|
||||
|
||||
assert Notification.objects.count() == 2
|
||||
|
||||
NotificationHandler.clear_all_notifications(user, workspace=workspace)
|
||||
|
||||
assert Notification.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_clear_direct_notifications_should_delete_them(
|
||||
data_fixture,
|
||||
):
|
||||
user = data_fixture.create_user()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
other_user = data_fixture.create_user(workspace=workspace)
|
||||
|
||||
data_fixture.create_workspace_notification(
|
||||
recipients=[user, other_user], workspace=workspace
|
||||
)
|
||||
|
||||
count_user_notifications = NotificationHandler.list_notifications(
|
||||
user, workspace=workspace
|
||||
).count
|
||||
count_other_user_notifications = NotificationHandler.list_notifications(
|
||||
other_user, workspace=workspace
|
||||
).count
|
||||
|
||||
assert Notification.objects.all().count() == 1
|
||||
assert count_user_notifications() == 1
|
||||
assert count_other_user_notifications() == 1
|
||||
|
||||
NotificationHandler.clear_all_notifications(user, workspace=workspace)
|
||||
|
||||
assert Notification.objects.all().count() == 1
|
||||
assert count_user_notifications() == 0
|
||||
assert count_other_user_notifications() == 1
|
||||
|
||||
NotificationHandler.clear_all_notifications(other_user, workspace=workspace)
|
||||
|
||||
assert Notification.objects.all().count() == 0
|
||||
assert count_user_notifications() == 0
|
||||
assert count_other_user_notifications() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_all_users_can_see_and_clear_broadcast_notifications(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
other_user = data_fixture.create_user()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
other_workspace = data_fixture.create_workspace(user=other_user)
|
||||
|
||||
notification = data_fixture.create_broadcast_notification()
|
||||
|
||||
user_notifications = NotificationHandler.list_notifications(
|
||||
user, workspace=workspace
|
||||
)
|
||||
user_unread_notifications_count = (
|
||||
lambda: NotificationHandler.get_unread_notifications_count(user)
|
||||
)
|
||||
other_user_notifications = NotificationHandler.list_notifications(
|
||||
other_user, workspace=workspace
|
||||
)
|
||||
other_user_unread_notifications_count = (
|
||||
lambda: NotificationHandler.get_unread_notifications_count(other_user)
|
||||
)
|
||||
|
||||
assert Notification.objects.count() == 1
|
||||
assert user_unread_notifications_count() == 1
|
||||
assert other_user_unread_notifications_count() == 1
|
||||
|
||||
NotificationHandler.mark_all_notifications_as_read(user, workspace=workspace)
|
||||
|
||||
assert user_unread_notifications_count() == 0
|
||||
assert user_notifications[0].read is True
|
||||
|
||||
assert other_user_unread_notifications_count() == 1
|
||||
assert other_user_notifications[0].read is False
|
||||
|
||||
NotificationHandler.mark_notification_as_read(other_user, notification)
|
||||
|
||||
assert other_user_unread_notifications_count() == 0
|
||||
|
||||
# notifications have been read by are still there, let's clear them
|
||||
|
||||
assert user_notifications.count() == 1
|
||||
assert other_user_notifications.count() == 1
|
||||
|
||||
NotificationHandler.clear_all_notifications(user, workspace=workspace)
|
||||
|
||||
assert user_notifications.count() == 0
|
||||
assert other_user_notifications.count() == 1
|
||||
|
||||
NotificationHandler.clear_all_notifications(other_user, workspace=other_workspace)
|
||||
|
||||
# broadcast notifications remain there until the notification is deleted
|
||||
assert NotificationRecipient.objects.filter(cleared=True).count() == 2
|
||||
assert Notification.objects.count() == 1
|
||||
|
||||
Notification.objects.all().delete()
|
||||
assert NotificationRecipient.objects.count() == 0
|
|
@ -0,0 +1,111 @@
|
|||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from baserow.core.notifications.handler import NotificationHandler
|
||||
from baserow.core.notifications.models import NotificationRecipient
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.core.notifications.signals.notification_created.send")
|
||||
def test_notification_created_signal_called(mock_notification_created, data_fixture):
|
||||
sender = data_fixture.create_user()
|
||||
recipient = data_fixture.create_user()
|
||||
|
||||
notification_recipients = NotificationHandler.create_notification_for_users(
|
||||
notification_type="test",
|
||||
recipients=[recipient],
|
||||
data={"test": True},
|
||||
sender=sender,
|
||||
)
|
||||
mock_notification_created.assert_called_once()
|
||||
args = mock_notification_created.call_args
|
||||
|
||||
assert args == call(
|
||||
sender=NotificationHandler,
|
||||
notification=notification_recipients[0].notification,
|
||||
notification_recipients=notification_recipients,
|
||||
user=sender,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.core.notifications.signals.notification_created.send")
|
||||
def test_notification_broadcast_created_signal_called(
|
||||
mock_notification_created, data_fixture
|
||||
):
|
||||
notification = NotificationHandler.create_broadcast_notification(
|
||||
notification_type="broadcast", data={"test": True}
|
||||
)
|
||||
mock_notification_created.assert_called_once()
|
||||
args = mock_notification_created.call_args
|
||||
|
||||
assert args == call(
|
||||
sender=NotificationHandler,
|
||||
notification=notification,
|
||||
notification_recipients=list(
|
||||
NotificationRecipient.objects.filter(
|
||||
recipient=None, notification=notification
|
||||
)
|
||||
),
|
||||
user=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.core.notifications.signals.notification_marked_as_read.send")
|
||||
def test_notification_marked_as_read_signal_called(
|
||||
mock_notification_marked_as_read, data_fixture
|
||||
):
|
||||
sender = data_fixture.create_user()
|
||||
workspace = data_fixture.create_workspace(user=sender)
|
||||
recipient = data_fixture.create_user(
|
||||
workspace=workspace, web_socket_id="web_socket_id"
|
||||
)
|
||||
notification = data_fixture.create_workspace_notification(
|
||||
recipients=[recipient], workspace=workspace, sender=sender
|
||||
)
|
||||
notification_recipient = NotificationHandler.mark_notification_as_read(
|
||||
recipient, notification
|
||||
)
|
||||
|
||||
mock_notification_marked_as_read.assert_called_once()
|
||||
args = mock_notification_marked_as_read.call_args
|
||||
|
||||
assert args == call(
|
||||
sender=NotificationHandler,
|
||||
user=recipient,
|
||||
notification=notification,
|
||||
notification_recipient=notification_recipient,
|
||||
ignore_web_socket_id="web_socket_id",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.core.notifications.signals.all_notifications_marked_as_read.send")
|
||||
def test_all_notifications_marked_as_read_signal_called(
|
||||
mock_all_notifications_marked_as_read, data_fixture
|
||||
):
|
||||
user = data_fixture.create_user()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
NotificationHandler.mark_all_notifications_as_read(user, workspace)
|
||||
|
||||
mock_all_notifications_marked_as_read.assert_called_once()
|
||||
args = mock_all_notifications_marked_as_read.call_args
|
||||
|
||||
assert args == call(sender=NotificationHandler, user=user, workspace=workspace)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.core.notifications.signals.all_notifications_cleared.send")
|
||||
def test_all_notifications_cleared_signal_called(
|
||||
mock_all_notifications_cleared, data_fixture
|
||||
):
|
||||
user = data_fixture.create_user()
|
||||
workspace = data_fixture.create_workspace(user=user)
|
||||
NotificationHandler.clear_all_notifications(user, workspace)
|
||||
|
||||
mock_all_notifications_cleared.assert_called_once()
|
||||
args = mock_all_notifications_cleared.call_args
|
||||
|
||||
assert args == call(sender=NotificationHandler, user=user, workspace=workspace)
|
|
@ -372,7 +372,6 @@ def test_get_permissions(data_fixture):
|
|||
)
|
||||
|
||||
result = CoreHandler().get_permissions(admin)
|
||||
print(result)
|
||||
|
||||
assert result == [
|
||||
{"name": "view_ownership", "permissions": {}},
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "feature",
|
||||
"message": "Create a notification panel to show notifications",
|
||||
"issue_number": 1775,
|
||||
"bullet_points": [],
|
||||
"created_at": "2023-06-30"
|
||||
}
|
|
@ -29,6 +29,11 @@ from baserow.contrib.database.table.operations import (
|
|||
)
|
||||
from baserow.core.exceptions import PermissionException
|
||||
from baserow.core.models import Application
|
||||
from baserow.core.notifications.operations import (
|
||||
ClearNotificationsOperationType,
|
||||
ListNotificationsOperationType,
|
||||
MarkNotificationAsReadOperationType,
|
||||
)
|
||||
from baserow.core.operations import (
|
||||
CreateWorkspaceOperationType,
|
||||
DeleteApplicationOperationType,
|
||||
|
@ -1045,8 +1050,12 @@ def test_get_permissions_object_with_teams(
|
|||
|
||||
perms = perm_manager.get_permissions_object(user, workspace=workspace_1)
|
||||
|
||||
assert all([not perm["default"] for perm in perms.values()])
|
||||
assert all([not perm["exceptions"] for perm in perms.values()])
|
||||
for operation_type in [
|
||||
UpdateApplicationOperationType,
|
||||
ReadDatabaseTableOperationType,
|
||||
]:
|
||||
assert perms[operation_type.type]["default"] is False
|
||||
assert perms[operation_type.type]["exceptions"] == []
|
||||
|
||||
# The user role should take the precedence
|
||||
RoleAssignmentHandler().assign_role(user, workspace_1, role=role_builder)
|
||||
|
@ -1254,6 +1263,9 @@ def test_all_operations_are_in_at_least_one_default_role(data_fixture):
|
|||
CreateWorkspaceOperationType.type,
|
||||
ListWorkspacesOperationType.type,
|
||||
UpdateSettingsOperationType.type,
|
||||
ClearNotificationsOperationType.type,
|
||||
ListNotificationsOperationType.type,
|
||||
MarkNotificationAsReadOperationType.type,
|
||||
]
|
||||
|
||||
all_ops_in_roles = set()
|
||||
|
|
|
@ -113,3 +113,11 @@ class BaserowPremiumConfig(AppConfig):
|
|||
from .permission_manager import ViewOwnershipPermissionManagerType
|
||||
|
||||
permission_manager_type_registry.register(ViewOwnershipPermissionManagerType())
|
||||
|
||||
from baserow_premium.row_comments.notification_types import (
|
||||
RowCommentMentionNotificationType,
|
||||
)
|
||||
|
||||
from baserow.core.notifications.registries import notification_type_registry
|
||||
|
||||
notification_type_registry.register(RowCommentMentionNotificationType())
|
||||
|
|
|
@ -115,7 +115,9 @@ class RowCommentHandler:
|
|||
context=table,
|
||||
)
|
||||
|
||||
queryset = RowComment.objects.select_related("table__database__workspace")
|
||||
queryset = RowComment.objects.select_related(
|
||||
"table__database__workspace"
|
||||
).prefetch_related("mentions")
|
||||
|
||||
try:
|
||||
row_comment = queryset.get(pk=comment_id)
|
||||
|
@ -173,7 +175,7 @@ class RowCommentHandler:
|
|||
raise InvalidRowCommentException()
|
||||
|
||||
try:
|
||||
mentioned_users = extract_mentioned_users_in_workspace(message, workspace)
|
||||
mentions = extract_mentioned_users_in_workspace(message, workspace)
|
||||
except ValueError:
|
||||
raise InvalidRowCommentMentionException()
|
||||
|
||||
|
@ -185,10 +187,12 @@ class RowCommentHandler:
|
|||
comment=message,
|
||||
)
|
||||
|
||||
if mentioned_users:
|
||||
row_comment.mentions.set(mentioned_users)
|
||||
if mentions:
|
||||
row_comment.mentions.set(mentions)
|
||||
|
||||
row_comment_created.send(cls, row_comment=row_comment, user=requesting_user)
|
||||
row_comment_created.send(
|
||||
cls, row_comment=row_comment, user=requesting_user, mentions=list(mentions)
|
||||
)
|
||||
return row_comment
|
||||
|
||||
@classmethod
|
||||
|
@ -229,20 +233,22 @@ class RowCommentHandler:
|
|||
raise InvalidRowCommentException()
|
||||
|
||||
try:
|
||||
mentioned_users = extract_mentioned_users_in_workspace(message, workspace)
|
||||
new_mentions = extract_mentioned_users_in_workspace(message, workspace)
|
||||
old_mentions = row_comment.mentions.all()
|
||||
except ValueError:
|
||||
raise InvalidRowCommentMentionException()
|
||||
|
||||
row_comment.message = message
|
||||
row_comment.save(update_fields=["message", "updated_on"])
|
||||
|
||||
if mentioned_users:
|
||||
row_comment.mentions.set(mentioned_users)
|
||||
if new_mentions:
|
||||
row_comment.mentions.set(new_mentions)
|
||||
|
||||
row_comment_updated.send(
|
||||
cls,
|
||||
row_comment=row_comment,
|
||||
user=requesting_user,
|
||||
mentions=list(set(new_mentions) - set(old_mentions)),
|
||||
)
|
||||
return row_comment
|
||||
|
||||
|
@ -275,4 +281,10 @@ class RowCommentHandler:
|
|||
|
||||
TrashHandler.trash(requesting_user, database.workspace, database, row_comment)
|
||||
|
||||
row_comment_deleted.send(cls, row_comment=row_comment, user=requesting_user)
|
||||
mentions = list(row_comment.mentions.all())
|
||||
row_comment_deleted.send(
|
||||
cls,
|
||||
row_comment=row_comment,
|
||||
user=requesting_user,
|
||||
mentions=mentions,
|
||||
)
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
from dataclasses import asdict, dataclass
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from baserow.core.notifications.handler import NotificationHandler
|
||||
from baserow.core.notifications.registries import NotificationType
|
||||
|
||||
from .signals import row_comment_created, row_comment_updated
|
||||
|
||||
|
||||
@dataclass
|
||||
class RowCommentMentionNotificationData:
|
||||
database_id: int
|
||||
database_name: str
|
||||
table_id: int
|
||||
table_name: str
|
||||
row_id: int
|
||||
comment_id: int
|
||||
message: str
|
||||
|
||||
@classmethod
|
||||
def from_row_comment(cls, row_comment):
|
||||
return cls(
|
||||
database_id=row_comment.table.database.id,
|
||||
database_name=row_comment.table.database.name,
|
||||
table_id=row_comment.table_id,
|
||||
table_name=row_comment.table.name,
|
||||
row_id=int(row_comment.row_id),
|
||||
comment_id=row_comment.id,
|
||||
message=row_comment.message,
|
||||
)
|
||||
|
||||
|
||||
class RowCommentMentionNotificationType(NotificationType):
|
||||
type = "row_comment_mention"
|
||||
|
||||
@classmethod
|
||||
def notify_mentioned_users(cls, row_comment, mentions):
|
||||
"""
|
||||
Creates a notification for each user that is mentioned in the comment.
|
||||
|
||||
:param row_comment: The comment that was created.
|
||||
:param mentions: The list of users that are mentioned.
|
||||
:return: The list of created notifications.
|
||||
"""
|
||||
|
||||
notification_data = RowCommentMentionNotificationData.from_row_comment(
|
||||
row_comment
|
||||
)
|
||||
NotificationHandler.create_notification_for_users(
|
||||
notification_type=cls.type,
|
||||
recipients=mentions,
|
||||
data=asdict(notification_data),
|
||||
sender=row_comment.user,
|
||||
workspace=row_comment.table.database.workspace,
|
||||
)
|
||||
|
||||
|
||||
@receiver(row_comment_created)
|
||||
def on_row_comment_created(sender, row_comment, user, mentions, **kwargs):
|
||||
if mentions:
|
||||
RowCommentMentionNotificationType.notify_mentioned_users(row_comment, mentions)
|
||||
|
||||
|
||||
@receiver(row_comment_updated)
|
||||
def on_row_comment_updated(sender, row_comment, user, mentions, **kwargs):
|
||||
if mentions:
|
||||
RowCommentMentionNotificationType.notify_mentioned_users(row_comment, mentions)
|
|
@ -1109,7 +1109,7 @@ def test_user_con_be_mentioned_in_message(premium_data_fixture, api_client):
|
|||
first_name="Test User 2", workspace=table.database.workspace
|
||||
)
|
||||
|
||||
message = premium_data_fixture.create_comment_message_from_mentions([user_2])
|
||||
message = premium_data_fixture.create_comment_message_with_mentions([user_2])
|
||||
|
||||
with freeze_time("2020-01-01 12:00"):
|
||||
response = api_client.post(
|
||||
|
@ -1147,12 +1147,12 @@ def test_user_cant_be_mentioned_in_comments_if_outside_workspace(
|
|||
user, token = premium_data_fixture.create_user_and_token(
|
||||
first_name="Test User", has_active_premium_license=True
|
||||
)
|
||||
table, fields, rows = premium_data_fixture.build_table(
|
||||
table, _, rows = premium_data_fixture.build_table(
|
||||
columns=[("text", "text")], rows=["first row", "second_row"], user=user
|
||||
)
|
||||
user_2 = premium_data_fixture.create_user(first_name="Test User 2")
|
||||
|
||||
message = premium_data_fixture.create_comment_message_from_mentions([user_2])
|
||||
message = premium_data_fixture.create_comment_message_with_mentions([user_2])
|
||||
|
||||
with freeze_time("2020-01-01 12:00"):
|
||||
response = api_client.post(
|
||||
|
@ -1164,10 +1164,8 @@ def test_user_cant_be_mentioned_in_comments_if_outside_workspace(
|
|||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_INVALID_COMMENT_MENTION"
|
||||
|
||||
assert RowComment.objects.first() is None
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert RowComment.objects.first().mentions.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
@ -1186,7 +1184,7 @@ def test_multiple_users_can_be_mentioned_in_a_comment(premium_data_fixture, api_
|
|||
first_name="Test User 3", workspace=table.database.workspace
|
||||
)
|
||||
|
||||
message = premium_data_fixture.create_comment_message_from_mentions(
|
||||
message = premium_data_fixture.create_comment_message_with_mentions(
|
||||
[user_2, user_3]
|
||||
)
|
||||
|
||||
|
|
|
@ -142,7 +142,7 @@ class PremiumFixtures:
|
|||
def create_comment_message_from_plain_text(self, plain_text):
|
||||
return prosemirror_doc_from_plain_text(plain_text)
|
||||
|
||||
def create_comment_message_from_mentions(self, mentions):
|
||||
def create_comment_message_with_mentions(self, mentions):
|
||||
return schema.node(
|
||||
"doc",
|
||||
{},
|
||||
|
|
|
@ -6,7 +6,6 @@ import pytest
|
|||
from baserow_premium.license.exceptions import FeaturesNotAvailableError
|
||||
from baserow_premium.row_comments.exceptions import (
|
||||
InvalidRowCommentException,
|
||||
InvalidRowCommentMentionException,
|
||||
RowCommentDoesNotExist,
|
||||
UserNotRowCommentAuthorException,
|
||||
)
|
||||
|
@ -102,7 +101,7 @@ def test_row_comment_created_signal_called(
|
|||
mock_row_comment_created.assert_called_once()
|
||||
args = mock_row_comment_created.call_args
|
||||
|
||||
assert args == call(RowCommentHandler, row_comment=c, user=user)
|
||||
assert args == call(RowCommentHandler, row_comment=c, user=user, mentions=[])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
@ -166,7 +165,7 @@ def test_row_comment_updated_signal_called(
|
|||
mock_row_comment_updated.assert_called_once()
|
||||
args = mock_row_comment_updated.call_args
|
||||
|
||||
assert args == call(RowCommentHandler, row_comment=c, user=user)
|
||||
assert args == call(RowCommentHandler, row_comment=c, user=user, mentions=[])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
@ -227,7 +226,7 @@ def test_row_comment_deleted_signal_called(
|
|||
mock_row_comment_deleted.assert_called_once()
|
||||
args = mock_row_comment_deleted.call_args
|
||||
|
||||
assert args == call(RowCommentHandler, row_comment=c, user=user)
|
||||
assert args == call(RowCommentHandler, row_comment=c, user=user, mentions=[])
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
|
@ -244,7 +243,7 @@ def test_row_comment_mentions_are_created(premium_data_fixture):
|
|||
has_active_premium_license=True,
|
||||
workspace=table.database.workspace,
|
||||
)
|
||||
message = premium_data_fixture.create_comment_message_from_mentions([user2])
|
||||
message = premium_data_fixture.create_comment_message_with_mentions([user2])
|
||||
|
||||
with freeze_time("2020-01-02 12:00"):
|
||||
c = RowCommentHandler.create_comment(user, table.id, rows[0].id, message)
|
||||
|
@ -266,7 +265,7 @@ def test_row_comment_cant_mention_user_outside_workspace(premium_data_fixture):
|
|||
has_active_premium_license=True,
|
||||
)
|
||||
|
||||
message = premium_data_fixture.create_comment_message_from_mentions([user2])
|
||||
message = premium_data_fixture.create_comment_message_with_mentions([user2])
|
||||
|
||||
with pytest.raises(InvalidRowCommentMentionException):
|
||||
RowCommentHandler.create_comment(user, table.id, rows[0].id, message)
|
||||
comment = RowCommentHandler.create_comment(user, table.id, rows[0].id, message)
|
||||
assert comment.mentions.count() == 0
|
||||
|
|
|
@ -0,0 +1,187 @@
|
|||
from unittest.mock import call, patch
|
||||
|
||||
from django.shortcuts import reverse
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from baserow_premium.row_comments.handler import RowCommentHandler
|
||||
from baserow_premium.row_comments.notification_types import (
|
||||
RowCommentMentionNotificationType,
|
||||
)
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.status import HTTP_200_OK
|
||||
|
||||
from baserow.core.notifications.handler import NotificationHandler
|
||||
from baserow.core.notifications.models import NotificationRecipient
|
||||
from baserow.test_utils.helpers import AnyInt
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(DEBUG=True)
|
||||
@patch("baserow.core.notifications.signals.notification_created.send")
|
||||
def test_notification_creation_on_creating_row_comment_mention(
|
||||
mocked_notification_created, api_client, premium_data_fixture
|
||||
):
|
||||
user_1, token_1 = premium_data_fixture.create_user_and_token(
|
||||
has_active_premium_license=True
|
||||
)
|
||||
table, _, rows = premium_data_fixture.build_table(
|
||||
columns=[("text", "text")], rows=["first row", "second_row"], user=user_1
|
||||
)
|
||||
workspace = table.database.workspace
|
||||
user_2, token_2 = premium_data_fixture.create_user_and_token(
|
||||
workspace=workspace, has_active_premium_license=True
|
||||
)
|
||||
|
||||
message = premium_data_fixture.create_comment_message_with_mentions([user_2])
|
||||
|
||||
with freeze_time("2020-01-01 12:00"):
|
||||
response = api_client.post(
|
||||
reverse(
|
||||
"api:premium:row_comments:list",
|
||||
kwargs={"table_id": table.id, "row_id": rows[0].id},
|
||||
),
|
||||
{"message": message},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token_1}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
comment_id = response.json()["id"]
|
||||
|
||||
assert mocked_notification_created.called_once()
|
||||
args = mocked_notification_created.call_args
|
||||
assert args == call(
|
||||
sender=NotificationHandler,
|
||||
notification=NotificationHandler.get_notification_by(
|
||||
user_2, data__contains={"comment_id": comment_id}
|
||||
),
|
||||
notification_recipients=list(
|
||||
NotificationRecipient.objects.filter(recipient=user_2)
|
||||
),
|
||||
user=user_1,
|
||||
)
|
||||
|
||||
# the user can see the notification in the list of notifications
|
||||
response = api_client.get(
|
||||
reverse("api:notifications:list", kwargs={"workspace_id": workspace.id}),
|
||||
HTTP_AUTHORIZATION=f"JWT {token_2}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
assert response_json == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"id": AnyInt(),
|
||||
"created_on": "2020-01-01T12:00:00Z",
|
||||
"type": RowCommentMentionNotificationType.type,
|
||||
"read": False,
|
||||
"sender": {
|
||||
"id": user_1.id,
|
||||
"username": user_1.username,
|
||||
"first_name": user_1.first_name,
|
||||
},
|
||||
"workspace": {"id": workspace.id},
|
||||
"data": {
|
||||
"database_id": table.database.id,
|
||||
"database_name": table.database.name,
|
||||
"table_id": table.id,
|
||||
"table_name": table.name,
|
||||
"row_id": rows[0].id,
|
||||
"comment_id": comment_id,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(DEBUG=True)
|
||||
@patch("baserow.core.notifications.signals.notification_created.send")
|
||||
def test_notify_only_new_mentions_when_updating_a_comment(
|
||||
mocked_notification_created, api_client, premium_data_fixture
|
||||
):
|
||||
user_1, token_1 = premium_data_fixture.create_user_and_token(
|
||||
has_active_premium_license=True
|
||||
)
|
||||
table, _, rows = premium_data_fixture.build_table(
|
||||
columns=[("text", "text")], rows=["first row", "second_row"], user=user_1
|
||||
)
|
||||
workspace = table.database.workspace
|
||||
user_2 = premium_data_fixture.create_user(
|
||||
workspace=workspace, has_active_premium_license=True
|
||||
)
|
||||
|
||||
message = premium_data_fixture.create_comment_message_with_mentions([user_2])
|
||||
with freeze_time("2020-01-01 11:00"):
|
||||
comment = RowCommentHandler.create_comment(
|
||||
user_1, table.id, rows[0].id, message
|
||||
)
|
||||
new_message = premium_data_fixture.create_comment_message_with_mentions(
|
||||
[user_1, user_2]
|
||||
)
|
||||
|
||||
with freeze_time("2020-01-01 12:00"):
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
"api:premium:row_comments:item",
|
||||
kwargs={"table_id": table.id, "comment_id": comment.id},
|
||||
),
|
||||
{"message": new_message},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token_1}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
comment_id = response.json()["id"]
|
||||
|
||||
assert mocked_notification_created.called_once()
|
||||
args = mocked_notification_created.call_args
|
||||
assert args == call(
|
||||
sender=NotificationHandler,
|
||||
notification=NotificationHandler.get_notification_by(
|
||||
user_1, data__contains={"comment_id": comment_id}
|
||||
),
|
||||
notification_recipients=list(
|
||||
NotificationRecipient.objects.filter(recipient=user_1)
|
||||
),
|
||||
user=user_1,
|
||||
)
|
||||
|
||||
# the user can see the notification in the list of notifications
|
||||
response = api_client.get(
|
||||
reverse("api:notifications:list", kwargs={"workspace_id": workspace.id}),
|
||||
HTTP_AUTHORIZATION=f"JWT {token_1}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
assert response_json == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"id": AnyInt(),
|
||||
"type": RowCommentMentionNotificationType.type,
|
||||
"created_on": "2020-01-01T12:00:00Z",
|
||||
"read": False,
|
||||
"sender": {
|
||||
"id": user_1.id,
|
||||
"username": user_1.username,
|
||||
"first_name": user_1.first_name,
|
||||
},
|
||||
"workspace": {"id": workspace.id},
|
||||
"data": {
|
||||
"database_id": table.database.id,
|
||||
"database_name": table.database.name,
|
||||
"table_id": table.id,
|
||||
"table_name": table.name,
|
||||
"row_id": rows[0].id,
|
||||
"comment_id": comment_id,
|
||||
"message": new_message,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
|
@ -37,11 +37,6 @@
|
|||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.row-comments__end-line {
|
||||
margin: 20px 20px;
|
||||
border-bottom: solid 1px #d9dbde;
|
||||
}
|
||||
|
||||
.row-comments__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
@ -188,7 +188,7 @@ export default {
|
|||
}
|
||||
},
|
||||
cloneCommentMessage() {
|
||||
return JSON.parse(JSON.stringify(this.comment.message))
|
||||
return structuredClone(this.comment.message)
|
||||
},
|
||||
startEdit() {
|
||||
this.$refs.commentContext.hide()
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<nuxt-link
|
||||
class="notification-panel__notification-link"
|
||||
event=""
|
||||
:to="url"
|
||||
@click.native="markAsReadAndHandleClick"
|
||||
>
|
||||
<div class="notification-panel__notification-content-title">
|
||||
<i18n path="rowCommentMentionNotification.title" tag="span">
|
||||
<template #sender>
|
||||
<strong>{{ notification.sender.first_name }}</strong>
|
||||
</template>
|
||||
<template #table>
|
||||
<strong>{{ notification.data.table_name }}</strong>
|
||||
</template>
|
||||
</i18n>
|
||||
</div>
|
||||
<RichTextEditor :editable="false" :value="notification.data.message" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RichTextEditor from '@baserow/modules/core/components/editor/RichTextEditor.vue'
|
||||
import notificationContent from '@baserow/modules/core/mixins/notificationContent'
|
||||
import { openRowEditModal } from '@baserow/modules/database/utils/router'
|
||||
|
||||
export default {
|
||||
name: 'RowCommentMentionNotification',
|
||||
components: {
|
||||
RichTextEditor,
|
||||
},
|
||||
mixins: [notificationContent],
|
||||
props: {
|
||||
notification: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
params() {
|
||||
const data = this.notification.data
|
||||
return {
|
||||
databaseId: data.database_id,
|
||||
tableId: data.table_id,
|
||||
rowId: data.row_id,
|
||||
}
|
||||
},
|
||||
url() {
|
||||
return {
|
||||
name: 'database-table-row',
|
||||
params: this.params,
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async handleClick(evt) {
|
||||
evt.preventDefault()
|
||||
this.$emit('close-panel')
|
||||
const { $store, $router, $route } = this
|
||||
await openRowEditModal({ $store, $router, $route }, this.params)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -53,6 +53,9 @@
|
|||
"edit": "Edit comment",
|
||||
"delete": "Delete comment"
|
||||
},
|
||||
"rowCommentMentionNotification": {
|
||||
"title": "{sender} mentioned you in {table}"
|
||||
},
|
||||
"trashType": {
|
||||
"row_comment": "row comment"
|
||||
},
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import { NotificationType } from '@baserow/modules/core/notificationTypes'
|
||||
import RowCommentMentionNotification from '@baserow_premium/components/row_comments/RowCommentMentionNotification'
|
||||
import NotificationSenderInitialsIcon from '@baserow/modules/core/components/notifications/NotificationSenderInitialsIcon'
|
||||
|
||||
export class RowCommentMentionNotificationType extends NotificationType {
|
||||
static getType() {
|
||||
return 'row_comment_mention'
|
||||
}
|
||||
|
||||
getIconComponent() {
|
||||
return NotificationSenderInitialsIcon
|
||||
}
|
||||
|
||||
getContentComponent() {
|
||||
return RowCommentMentionNotification
|
||||
}
|
||||
}
|
|
@ -38,6 +38,7 @@ import pl from '@baserow_premium/locales/pl.json'
|
|||
import { PremiumLicenseType } from '@baserow_premium/licenseTypes'
|
||||
import { PersonalViewOwnershipType } from '@baserow_premium/viewOwnershipTypes'
|
||||
import { ViewOwnershipPermissionManagerType } from '@baserow_premium/permissionManagerTypes'
|
||||
import { RowCommentMentionNotificationType } from '@baserow_premium/notificationTypes'
|
||||
|
||||
export default (context) => {
|
||||
const { store, app, isDev } = context
|
||||
|
@ -128,4 +129,8 @@ export default (context) => {
|
|||
'application',
|
||||
new PremiumDatabaseApplicationType(context)
|
||||
)
|
||||
app.$registry.register(
|
||||
'notification',
|
||||
new RowCommentMentionNotificationType(context)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -227,6 +227,8 @@
|
|||
"tableDoesNotExistDescription": "The action couldn't be completed because the related table doesn't exist anymore.",
|
||||
"rowDoesNotExistTitle": "Row doesn't exist.",
|
||||
"rowDoesNotExistDescription": "The action couldn't be completed because the related row doesn't exist anymore.",
|
||||
"notificationDoesNotExistTitle": "Notification doesn't exist.",
|
||||
"notificationDoesNotExistDescription": "The action couldn't be completed because the related doesn't exist anymore.",
|
||||
"fileSizeTooLargeTitle": "File too large",
|
||||
"fileSizeTooLargeDescription": "The provided file is too large.",
|
||||
"invalidFileTitle": "Invalid file",
|
||||
|
|
|
@ -123,3 +123,4 @@
|
|||
@import 'thumbnail';
|
||||
@import 'integrations/all';
|
||||
@import 'editable';
|
||||
@import 'notification_panel';
|
||||
|
|
|
@ -0,0 +1,162 @@
|
|||
.notification-panel {
|
||||
position: fixed;
|
||||
width: 400px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
left: 248px;
|
||||
z-index: $z-index-context;
|
||||
white-space: nowrap;
|
||||
background-color: $white;
|
||||
border-radius: 6px;
|
||||
border: 1px solid $color-neutral-200;
|
||||
box-shadow: 0 2px 6px 0 rgba($black, 0.16);
|
||||
}
|
||||
|
||||
.notification-panel__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid $color-neutral-200;
|
||||
}
|
||||
|
||||
.notification-panel__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-panel__action {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
text-align: right;
|
||||
color: $color-primary-500;
|
||||
}
|
||||
|
||||
.notification-panel__notification {
|
||||
padding: 18px 16px 16px 16px;
|
||||
border-bottom: 1px solid $color-neutral-200;
|
||||
display: flex;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.notification-panel__notification--unread {
|
||||
background-color: #f4fafe;
|
||||
}
|
||||
|
||||
.notification-panel__notification-icon {
|
||||
flex: 0 0 34px;
|
||||
|
||||
img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 2px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-panel__notification-content {
|
||||
flex-grow: 1;
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-panel__notification-link,
|
||||
.notification-panel__notification-link:visited,
|
||||
.notification-panel__notification-link:hover,
|
||||
.notification-panel__notification-link:focus,
|
||||
.notification-panel__notification-link:active {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.notification-panel__notification-content-title {
|
||||
white-space: normal;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
font-weight: 600;
|
||||
max-height: 142px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
p {
|
||||
color: $color-neutral-600;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notification-panel__notification-status {
|
||||
flex: 0 0 16px;
|
||||
text-align: right;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 100%;
|
||||
background-color: $color-primary-500;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-panel__notification-time {
|
||||
color: $color-neutral-400;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.notification-panel__notification-user-initials {
|
||||
flex: 0 0 24px;
|
||||
font-weight: bold;
|
||||
color: $white;
|
||||
background-color: $color-primary-500;
|
||||
border-radius: 100%;
|
||||
margin-right: 12px;
|
||||
|
||||
@include center-text(24px, 15px);
|
||||
}
|
||||
|
||||
.notification-panel__body {
|
||||
.infinite-scroll {
|
||||
top: 49px; // the height of the header + 1px for the border
|
||||
}
|
||||
}
|
||||
|
||||
.notification-panel__empty {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
margin: 0 52px 0 52px;
|
||||
height: 100%;
|
||||
border-top-left-radius: 6px;
|
||||
}
|
||||
|
||||
.notification-panel__empty-icon {
|
||||
font-size: 30px;
|
||||
margin-bottom: 30px;
|
||||
color: $color-primary-500;
|
||||
}
|
||||
|
||||
.notification-panel__empty-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 160%;
|
||||
margin-bottom: 12px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.notification-panel__empty-text {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 160%;
|
||||
margin-bottom: 30px;
|
||||
white-space: normal;
|
||||
color: $color-neutral-500;
|
||||
}
|
|
@ -83,6 +83,10 @@
|
|||
border-radius: 3px;
|
||||
user-select: none;
|
||||
|
||||
&.select__item--has-notification {
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
@ -150,6 +154,10 @@
|
|||
|
||||
@extend %ellipsis;
|
||||
@extend %select__item-size;
|
||||
|
||||
span:first-child {
|
||||
@extend %ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.select__item-name-text {
|
||||
|
|
|
@ -232,3 +232,13 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar__unread-notifications-icon {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 32px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 100%;
|
||||
background-color: $color-primary-500;
|
||||
}
|
||||
|
|
|
@ -62,9 +62,14 @@
|
|||
}
|
||||
|
||||
&.tree__action--has-options,
|
||||
&.tree__action--has-counter,
|
||||
&.tree__action--has-right-icon {
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
&.tree__action--has-notification {
|
||||
padding-right: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.tree__link {
|
||||
|
@ -241,3 +246,19 @@
|
|||
color: $color-neutral-700;
|
||||
}
|
||||
}
|
||||
|
||||
.tree__counter {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
width: 19px;
|
||||
height: 16px;
|
||||
border-radius: 80px;
|
||||
background-color: $color-primary-500;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
line-height: 16px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
|
212
web-frontend/modules/core/components/NotificationPanel.vue
Normal file
212
web-frontend/modules/core/components/NotificationPanel.vue
Normal file
|
@ -0,0 +1,212 @@
|
|||
<template>
|
||||
<div class="notification-panel" :class="{ 'visibility-hidden': !open }">
|
||||
<div class="notification-panel__head">
|
||||
<div class="notification-panel__title">
|
||||
{{ $t('notificationPanel.title') }}
|
||||
</div>
|
||||
<div v-show="totalCount > 0" class="notification-panel__actions">
|
||||
<a
|
||||
v-show="unreadCount > 0"
|
||||
class="notification-panel__action"
|
||||
@click="markAllAsRead"
|
||||
>
|
||||
{{ $t('notificationPanel.markAllAsRead') }}
|
||||
</a>
|
||||
<a
|
||||
class="notification-panel__action"
|
||||
@click="$refs.clearAllConfirmModal.show()"
|
||||
>
|
||||
{{ $t('notificationPanel.clearAll') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!loaded && loading" class="loading-absolute-center"></div>
|
||||
<div v-else-if="totalCount === 0" class="notification-panel__empty">
|
||||
<i class="notification-panel__empty-icon fas fa-bell-slash"></i>
|
||||
<div class="notification-panel__empty-title">
|
||||
{{ $t('notificationPanel.noNotificationTitle') }}
|
||||
</div>
|
||||
<div class="notification-panel__empty-text">
|
||||
{{ $t('notificationPanel.noNotification') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="notification-panel__body">
|
||||
<InfiniteScroll
|
||||
ref="infiniteScroll"
|
||||
:current-count="currentCount"
|
||||
:max-count="totalCount"
|
||||
:loading="loading"
|
||||
:render-end="false"
|
||||
@load-next-page="loadNextPage"
|
||||
>
|
||||
<template #default>
|
||||
<div
|
||||
v-for="(notification, index) in notifications"
|
||||
:key="index"
|
||||
class="notification-panel__notification"
|
||||
:class="{
|
||||
'notification-panel__notification--unread': !notification.read,
|
||||
}"
|
||||
>
|
||||
<div class="notification-panel__notification-icon">
|
||||
<component
|
||||
:is="getNotificationIcon(notification)"
|
||||
:notification="notification"
|
||||
v-bind="getNotificationIconProps(notification)"
|
||||
>
|
||||
</component>
|
||||
</div>
|
||||
<div class="notification-panel__notification-content">
|
||||
<component
|
||||
:is="getNotificationContent(notification)"
|
||||
:notification="notification"
|
||||
@close-panel="hide"
|
||||
>
|
||||
</component>
|
||||
<div class="notification-panel__notification-time">
|
||||
{{ timeAgo(notification.created_on) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="notification-panel__notification-status">
|
||||
<span v-if="!notification.read"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
<ClearAllNotificationsConfirmModal
|
||||
ref="clearAllConfirmModal"
|
||||
@confirm="
|
||||
($event) => {
|
||||
$event.preventDefault()
|
||||
$event.stopPropagation()
|
||||
clearAll()
|
||||
}
|
||||
"
|
||||
@cancel="
|
||||
($event) => {
|
||||
$event.preventDefault()
|
||||
$event.stopPropagation()
|
||||
}
|
||||
"
|
||||
></ClearAllNotificationsConfirmModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
import moment from '@baserow/modules/core/moment'
|
||||
import { isElement, onClickOutside } from '@baserow/modules/core/utils/dom'
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
import InfiniteScroll from '@baserow/modules/core/components/helpers/InfiniteScroll'
|
||||
import ClearAllNotificationsConfirmModal from '@baserow/modules/core/components/modals/ClearAllNotificationsConfirmModal'
|
||||
import MoveToBody from '@baserow/modules/core/mixins/moveToBody'
|
||||
|
||||
export default {
|
||||
name: 'NotificationPanel',
|
||||
components: {
|
||||
ClearAllNotificationsConfirmModal,
|
||||
InfiniteScroll,
|
||||
},
|
||||
mixins: [MoveToBody],
|
||||
data() {
|
||||
return {
|
||||
open: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
workspaceId: 'notification/getWorkspaceId',
|
||||
notifications: 'notification/getAll',
|
||||
loading: 'notification/getLoading',
|
||||
loaded: 'notification/getLoaded',
|
||||
unreadCount: 'notification/getUnreadCount',
|
||||
currentCount: 'notification/getCurrentCount',
|
||||
totalCount: 'notification/getTotalCount',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
async initialLoad() {
|
||||
try {
|
||||
await this.$store.dispatch('notification/fetchAll', {
|
||||
workspaceId: this.workspaceId,
|
||||
})
|
||||
} catch (e) {
|
||||
notifyIf(e, 'application')
|
||||
}
|
||||
},
|
||||
show(target) {
|
||||
this.open = true
|
||||
const opener = target
|
||||
const removeOnClickOutsideHandler = onClickOutside(this.$el, (target) => {
|
||||
if (
|
||||
this.open &&
|
||||
!isElement(opener, target) &&
|
||||
!this.moveToBody.children.some((child) => {
|
||||
return isElement(child.$el, target)
|
||||
})
|
||||
) {
|
||||
this.hide()
|
||||
}
|
||||
})
|
||||
this.$once('hidden', removeOnClickOutsideHandler)
|
||||
|
||||
if (!this.loaded) {
|
||||
this.initialLoad()
|
||||
}
|
||||
this.$emit('shown')
|
||||
},
|
||||
hide() {
|
||||
this.open = false
|
||||
this.opener = null
|
||||
this.$emit('hidden')
|
||||
},
|
||||
toggle(target) {
|
||||
if (this.open) {
|
||||
this.hide()
|
||||
} else {
|
||||
this.show(target)
|
||||
}
|
||||
},
|
||||
async markAllAsRead() {
|
||||
try {
|
||||
await this.$store.dispatch('notification/markAllAsRead')
|
||||
} catch (error) {
|
||||
notifyIf(error, 'application')
|
||||
}
|
||||
},
|
||||
async clearAll() {
|
||||
try {
|
||||
await this.$store.dispatch('notification/clearAll')
|
||||
} catch (error) {
|
||||
notifyIf(error, 'application')
|
||||
}
|
||||
},
|
||||
getNotificationIcon(notification) {
|
||||
return this.$registry
|
||||
.get('notification', notification.type)
|
||||
.getIconComponent()
|
||||
},
|
||||
getNotificationIconProps(notification) {
|
||||
return this.$registry
|
||||
.get('notification', notification.type)
|
||||
.getIconComponentProps()
|
||||
},
|
||||
getNotificationContent(notification) {
|
||||
return this.$registry
|
||||
.get('notification', notification.type)
|
||||
.getContentComponent()
|
||||
},
|
||||
timeAgo(timestamp) {
|
||||
return moment.utc(timestamp).fromNow()
|
||||
},
|
||||
async loadNextPage() {
|
||||
try {
|
||||
await this.$store.dispatch('notification/fetchNextSetOfNotifications')
|
||||
} catch (e) {
|
||||
notifyIf(e, 'application')
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -76,8 +76,14 @@ export default {
|
|||
return this.collaborators
|
||||
},
|
||||
onKeyDown({ event }) {
|
||||
if (event.key === 'Enter' && this.open) {
|
||||
return true // insert the selected item
|
||||
if (this.open) {
|
||||
if (event.key === 'Enter') {
|
||||
return true // insert the selected item
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
this.select(this.hover)
|
||||
return true
|
||||
}
|
||||
}
|
||||
},
|
||||
_select(collaborator) {
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<Modal small>
|
||||
<h2 class="box__title">
|
||||
{{ $t('clearAllNotificationsConfirmModal.title') }}
|
||||
</h2>
|
||||
<p>
|
||||
{{ $t('clearAllNotificationsConfirmModal.message') }}
|
||||
</p>
|
||||
<div>
|
||||
<div class="actions">
|
||||
<ul class="action__links">
|
||||
<li>
|
||||
<a
|
||||
@click.prevent="
|
||||
$emit('cancel', $event)
|
||||
hide()
|
||||
"
|
||||
>{{ $t('action.cancel') }}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
class="button button--error"
|
||||
@click.prevent="
|
||||
$emit('confirm', $event)
|
||||
hide()
|
||||
"
|
||||
>
|
||||
{{ $t('action.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import modal from '@baserow/modules/core/mixins/modal'
|
||||
|
||||
export default {
|
||||
name: 'ClearAllNotificationsConfirmModal',
|
||||
mixins: [modal],
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<a
|
||||
class="notification-panel__notification-link"
|
||||
:href="notification.release_notes_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click="markAsReadAndHandleClick"
|
||||
>
|
||||
<div class="notification-panel__notification-content-title">
|
||||
<i18n path="versionUpgradeNotification.title" tag="span">
|
||||
<template #version>
|
||||
<strong>{{ `Baserow v${notification.data.version}` }}</strong>
|
||||
</template>
|
||||
</i18n>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import notificationContent from '@baserow/modules/core/mixins/notificationContent'
|
||||
|
||||
export default {
|
||||
name: 'BaserowVersionUpgradeNotification',
|
||||
mixins: [notificationContent],
|
||||
props: {
|
||||
notification: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,14 @@
|
|||
<template>
|
||||
<img :src="icon" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,12 @@
|
|||
<template functional>
|
||||
<div class="notification-panel__notification-user-initials">
|
||||
{{ props.notification.sender.first_name | nameAbbreviation }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'NotificationSenderInitialsIcon',
|
||||
functional: true,
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<a
|
||||
href="#"
|
||||
class="notification-panel__notification-link"
|
||||
@click="markAsReadAndHandleClick"
|
||||
>
|
||||
<div class="notification-panel__notification-content-title">
|
||||
<i18n path="workspaceInvitationAcceptedNotification.title" tag="span">
|
||||
<template #sender>
|
||||
<strong>{{ notification.sender.first_name }}</strong>
|
||||
</template>
|
||||
<template #workspaceName>
|
||||
<strong>{{ notification.data.invited_to_workspace_name }}</strong>
|
||||
</template>
|
||||
</i18n>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import notificationContent from '@baserow/modules/core/mixins/notificationContent'
|
||||
|
||||
export default {
|
||||
name: 'WorkspaceInvitationAcceptedNotification',
|
||||
mixins: [notificationContent],
|
||||
props: {
|
||||
notification: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,38 @@
|
|||
<template>
|
||||
<nuxt-link
|
||||
class="notification-panel__notification-link"
|
||||
:to="{ name: 'dashboard' }"
|
||||
@click.native="markAsReadAndHandleClick"
|
||||
>
|
||||
<div class="notification-panel__notification-content-title">
|
||||
<i18n path="workspaceInvitationCreatedNotification.title" tag="span">
|
||||
<template #sender>
|
||||
<strong>{{ notification.sender.first_name }}</strong>
|
||||
</template>
|
||||
<template #workspaceName>
|
||||
<strong>{{ notification.data.invited_to_workspace_name }}</strong>
|
||||
</template>
|
||||
</i18n>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import notificationContent from '@baserow/modules/core/mixins/notificationContent'
|
||||
|
||||
export default {
|
||||
name: 'WorkspaceInvitationCreatedNotification',
|
||||
mixins: [notificationContent],
|
||||
props: {
|
||||
notification: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleClick() {
|
||||
this.$emit('close-panel')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<a
|
||||
href="#"
|
||||
class="notification-panel__notification-link"
|
||||
@click="markAsReadAndHandleClick"
|
||||
>
|
||||
<div class="notification-panel__notification-content-title">
|
||||
<i18n path="workspaceInvitationRejectedNotification.title" tag="span">
|
||||
<template #sender>
|
||||
<strong>{{ notification.sender.first_name }}</strong>
|
||||
</template>
|
||||
<template #workspaceName>
|
||||
<strong>{{ notification.data.invited_to_workspace_name }}</strong>
|
||||
</template>
|
||||
</i18n>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import notificationContent from '@baserow/modules/core/mixins/notificationContent'
|
||||
|
||||
export default {
|
||||
name: 'WorkspaceInvitationRejectedNotification',
|
||||
mixins: [notificationContent],
|
||||
props: {
|
||||
notification: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -113,7 +113,14 @@
|
|||
</li>
|
||||
<template v-if="hasSelectedWorkspace && !isCollapsed">
|
||||
<li class="tree__item margin-top-2">
|
||||
<div class="tree__action tree__action--has-options">
|
||||
<div
|
||||
:title="selectedWorkspace.name"
|
||||
class="tree__action tree__action--has-options"
|
||||
:class="{
|
||||
'tree__action--has-notification':
|
||||
unreadNotificationsInOtherWorkspaces,
|
||||
}"
|
||||
>
|
||||
<a
|
||||
ref="workspaceSelectToggle"
|
||||
class="tree__link tree__link--group"
|
||||
|
@ -130,8 +137,12 @@
|
|||
ref="rename"
|
||||
:value="selectedWorkspace.name"
|
||||
@change="renameWorkspace(selectedWorkspace, $event)"
|
||||
></Editable
|
||||
></a>
|
||||
></Editable>
|
||||
</a>
|
||||
<span
|
||||
v-if="unreadNotificationsInOtherWorkspaces"
|
||||
class="sidebar__unread-notifications-icon"
|
||||
></span>
|
||||
<a
|
||||
ref="contextLink"
|
||||
class="tree__options"
|
||||
|
@ -154,6 +165,21 @@
|
|||
></WorkspaceContext>
|
||||
</div>
|
||||
</li>
|
||||
<li class="tree__item">
|
||||
<div class="tree__action tree__action--has-counter">
|
||||
<a
|
||||
class="tree__link"
|
||||
@click="$refs.notificationPanel.toggle($event.currentTarget)"
|
||||
>
|
||||
<i class="tree__icon tree__icon--type fas fa-bell"></i>
|
||||
{{ $t('sidebar.notifications') }}
|
||||
</a>
|
||||
<span v-show="unreadNotificationCount" class="tree__counter">{{
|
||||
unreadNotificationCount >= 10 ? '9+' : unreadNotificationCount
|
||||
}}</span>
|
||||
</div>
|
||||
<NotificationPanel ref="notificationPanel" />
|
||||
</li>
|
||||
<li
|
||||
v-if="
|
||||
$hasPermission(
|
||||
|
@ -366,6 +392,7 @@ import undoRedo from '@baserow/modules/core/mixins/undoRedo'
|
|||
import BaserowLogo from '@baserow/modules/core/components/BaserowLogo'
|
||||
import WorkspaceMemberInviteModal from '@baserow/modules/core/components/workspace/WorkspaceMemberInviteModal'
|
||||
import { logoutAndRedirectToLogin } from '@baserow/modules/core/utils/auth'
|
||||
import NotificationPanel from '@baserow/modules/core/components/NotificationPanel'
|
||||
|
||||
export default {
|
||||
name: 'Sidebar',
|
||||
|
@ -380,6 +407,7 @@ export default {
|
|||
CreateWorkspaceModal,
|
||||
TrashModal,
|
||||
WorkspaceMemberInviteModal,
|
||||
NotificationPanel,
|
||||
},
|
||||
mixins: [editWorkspace, undoRedo],
|
||||
data() {
|
||||
|
@ -449,6 +477,9 @@ export default {
|
|||
email: 'auth/getUsername',
|
||||
hasSelectedWorkspace: 'workspace/hasSelected',
|
||||
isCollapsed: 'sidebar/isCollapsed',
|
||||
unreadNotificationCount: 'notification/getUnreadCount',
|
||||
unreadNotificationsInOtherWorkspaces:
|
||||
'notification/anyOtherWorkspaceWithUnread',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -33,7 +33,6 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import WorkspaceService from '@baserow/modules/core/services/workspace'
|
||||
import ApplicationService from '@baserow/modules/core/services/application'
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
|
||||
|
@ -56,8 +55,10 @@ export default {
|
|||
this.rejectLoading = true
|
||||
|
||||
try {
|
||||
await WorkspaceService(this.$client).rejectInvitation(invitation.id)
|
||||
this.$emit('remove', invitation)
|
||||
await this.$store.dispatch(
|
||||
'auth/rejectWorkspaceInvitation',
|
||||
invitation.id
|
||||
)
|
||||
} catch (error) {
|
||||
this.rejectLoading = false
|
||||
notifyIf(error, 'workspace')
|
||||
|
@ -71,11 +72,12 @@ export default {
|
|||
this.acceptLoading = true
|
||||
|
||||
try {
|
||||
const { data: workspace } = await WorkspaceService(
|
||||
this.$client
|
||||
).acceptInvitation(invitation.id)
|
||||
const workspace = await this.$store.dispatch(
|
||||
'auth/acceptWorkspaceInvitation',
|
||||
invitation.id
|
||||
)
|
||||
|
||||
this.$emit('invitation-accepted', { invitation, workspace })
|
||||
this.$emit('invitation-accepted', { workspace })
|
||||
|
||||
// The accept endpoint returns a workspace user object that we can add to the
|
||||
// store. Also the applications that we just fetched can be added to the
|
||||
|
@ -101,7 +103,6 @@ export default {
|
|||
this.$store.dispatch('application/forceCreate', application)
|
||||
})
|
||||
}
|
||||
this.$emit('remove', invitation)
|
||||
} catch (error) {
|
||||
this.acceptLoading = false
|
||||
notifyIf(error, 'workspace')
|
||||
|
|
|
@ -5,15 +5,20 @@
|
|||
active: workspace._.selected,
|
||||
'select__item--loading':
|
||||
workspace._.loading || workspace._.additionalLoading,
|
||||
'select__item--has-notification': hasUnreadNotifications,
|
||||
}"
|
||||
>
|
||||
<a class="select__item-link" @click="selectWorkspace(workspace)">
|
||||
<div class="select__item-name">
|
||||
<div :title="workspace.name" class="select__item-name">
|
||||
<Editable
|
||||
ref="rename"
|
||||
:value="workspace.name"
|
||||
@change="renameWorkspace(workspace, $event)"
|
||||
></Editable>
|
||||
<span
|
||||
v-if="hasUnreadNotifications"
|
||||
class="sidebar__unread-notifications-icon"
|
||||
></span>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
|
@ -46,5 +51,12 @@ export default {
|
|||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
hasUnreadNotifications() {
|
||||
return this.$store.getters['notification/workspaceHasUnread'](
|
||||
this.workspace.id
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -15,11 +15,9 @@ export default ({ VueComponent, context }) => ({
|
|||
items: ({ query }) => {
|
||||
const { $store } = context
|
||||
const workspace = $store.getters['workspace/getSelected']
|
||||
const loggedUserId = $store.getters['auth/getUserId']
|
||||
return workspace.users.filter(
|
||||
(user) =>
|
||||
user.name.toLowerCase().includes(query.toLowerCase()) &&
|
||||
user.user_id !== loggedUserId &&
|
||||
user.to_be_deleted === false
|
||||
)
|
||||
},
|
||||
|
|
|
@ -30,7 +30,8 @@
|
|||
"admin": "Admin",
|
||||
"dashboard": "Dashboard",
|
||||
"trash": "Trash",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"notifications": "Notifications"
|
||||
},
|
||||
"accountForm": {
|
||||
"nameLabel": "Your name",
|
||||
|
@ -45,6 +46,17 @@
|
|||
"settingsModal": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"notificationPanel": {
|
||||
"title": "Notifications",
|
||||
"markAllAsRead": "Mark all as read",
|
||||
"clearAll": "Clear all",
|
||||
"noNotificationTitle": "You don't have any notifications",
|
||||
"noNotification": "We'll notify you about important updates and any time you're mentioned on Baserow."
|
||||
},
|
||||
"clearAllNotificationsConfirmModal": {
|
||||
"title": "Are you sure you want to clear all notifications?",
|
||||
"message": "All the notifications will be permanently deleted and you won't be able to see them again."
|
||||
},
|
||||
"passwordSettings": {
|
||||
"title": "Change password",
|
||||
"changedTitle": "Password changed",
|
||||
|
@ -534,5 +546,17 @@
|
|||
},
|
||||
"richTextEditorMentionsList": {
|
||||
"notFound": "No users found"
|
||||
},
|
||||
"workspaceInvitationAcceptedNotification": {
|
||||
"title": "{sender} has accepted your invitation to join {workspaceName}"
|
||||
},
|
||||
"workspaceInvitationRejectedNotification": {
|
||||
"title": "{sender} has rejected your invitation to join {workspaceName}"
|
||||
},
|
||||
"workspaceInvitationCreatedNotification": {
|
||||
"title": "{sender} has invited you to join {workspaceName}"
|
||||
},
|
||||
"versionUpgradeNotification": {
|
||||
"title": "{version} is here! Check out what's new."
|
||||
}
|
||||
}
|
||||
|
|
26
web-frontend/modules/core/mixins/notificationContent.js
Normal file
26
web-frontend/modules/core/mixins/notificationContent.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
markAsReadAndHandleClick(evt) {
|
||||
this.$emit('click')
|
||||
|
||||
this.markAsRead()
|
||||
|
||||
if (typeof this.handleClick === 'function') {
|
||||
this.handleClick(evt)
|
||||
}
|
||||
},
|
||||
async markAsRead() {
|
||||
const notification = this.notification
|
||||
if (notification.read) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.$store.dispatch('notification/markAsRead', { notification })
|
||||
} catch (err) {
|
||||
notifyIf(err, 'error')
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
82
web-frontend/modules/core/notificationTypes.js
Normal file
82
web-frontend/modules/core/notificationTypes.js
Normal file
|
@ -0,0 +1,82 @@
|
|||
import { Registerable } from '@baserow/modules/core/registry'
|
||||
import NotificationSenderInitialsIcon from '@baserow/modules/core/components/notifications/NotificationSenderInitialsIcon'
|
||||
import WorkspaceInvitationCreatedNotification from '@baserow/modules/core/components/notifications/WorkspaceInvitationCreatedNotification'
|
||||
import WorkspaceInvitationAcceptedNotification from '@baserow/modules/core/components/notifications/WorkspaceInvitationAcceptedNotification'
|
||||
import WorkspaceInvitationRejectedNotification from '@baserow/modules/core/components/notifications/WorkspaceInvitationRejectedNotification'
|
||||
import BaserowVersionUpgradeNotification from '@baserow/modules/core/components/notifications/BaserowVersionUpgradeNotification'
|
||||
import NotificationImgIcon from '@baserow/modules/core/components/notifications/NotificationImgIcon'
|
||||
import BaserowIcon from '@baserow/modules/core/static/img/logoOnly.svg'
|
||||
|
||||
export class NotificationType extends Registerable {
|
||||
getIconComponent() {
|
||||
throw new Error('getIconComponent not implemented')
|
||||
}
|
||||
|
||||
getContentComponent() {
|
||||
throw new Error('getContentComponent not implemented')
|
||||
}
|
||||
|
||||
getIconComponentProps() {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceInvitationCreatedNotificationType extends NotificationType {
|
||||
static getType() {
|
||||
return 'workspace_invitation_created'
|
||||
}
|
||||
|
||||
getIconComponent() {
|
||||
return NotificationSenderInitialsIcon
|
||||
}
|
||||
|
||||
getContentComponent() {
|
||||
return WorkspaceInvitationCreatedNotification
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceInvitationAcceptedNotificationType extends NotificationType {
|
||||
static getType() {
|
||||
return 'workspace_invitation_accepted'
|
||||
}
|
||||
|
||||
getIconComponent() {
|
||||
return NotificationSenderInitialsIcon
|
||||
}
|
||||
|
||||
getContentComponent() {
|
||||
return WorkspaceInvitationAcceptedNotification
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceInvitationRejectedNotificationType extends NotificationType {
|
||||
static getType() {
|
||||
return 'workspace_invitation_rejected'
|
||||
}
|
||||
|
||||
getIconComponent() {
|
||||
return NotificationSenderInitialsIcon
|
||||
}
|
||||
|
||||
getContentComponent() {
|
||||
return WorkspaceInvitationRejectedNotification
|
||||
}
|
||||
}
|
||||
|
||||
export class BaserowVersionUpgradeNotificationType extends NotificationType {
|
||||
static getType() {
|
||||
return 'baserow_version_upgrade'
|
||||
}
|
||||
|
||||
getIconComponent() {
|
||||
return NotificationImgIcon
|
||||
}
|
||||
|
||||
getIconComponentProps() {
|
||||
return { icon: BaserowIcon }
|
||||
}
|
||||
|
||||
getContentComponent() {
|
||||
return BaserowVersionUpgradeNotification
|
||||
}
|
||||
}
|
|
@ -6,7 +6,6 @@
|
|||
v-for="invitation in workspaceInvitations"
|
||||
:key="'invitation-' + invitation.id"
|
||||
:invitation="invitation"
|
||||
@remove="removeInvitation($event)"
|
||||
@invitation-accepted="invitationAccepted($event)"
|
||||
></WorkspaceInvitation>
|
||||
<div class="dashboard__container">
|
||||
|
@ -61,7 +60,6 @@ import DashboardWorkspace from '@baserow/modules/core/components/dashboard/Dashb
|
|||
import DashboardHelp from '@baserow/modules/core/components/dashboard/DashboardHelp'
|
||||
import DashboardNoWorkspaces from '@baserow/modules/core/components/dashboard/DashboardNoWorkspaces'
|
||||
import DashboardSidebar from '@baserow/modules/core/components/dashboard/DashboardSidebar'
|
||||
import AuthService from '@baserow/modules/core/services/auth'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -78,13 +76,11 @@ export default {
|
|||
* pending workspace invitations.
|
||||
*/
|
||||
async asyncData(context) {
|
||||
const { error, app } = context
|
||||
const { error, app, store } = context
|
||||
try {
|
||||
const { data } = await AuthService(app.$client).dashboard()
|
||||
let asyncData = {
|
||||
workspaceInvitations: data.workspace_invitations,
|
||||
workspaceComponentArguments: {},
|
||||
}
|
||||
await store.dispatch('auth/fetchWorkspaceInvitations')
|
||||
let asyncData = { workspaceComponentArguments: {} }
|
||||
|
||||
// Loop over all the plugin and call the `fetchAsyncDashboardData` because there
|
||||
// might be plugins that extend the dashboard and we want to fetch that async data
|
||||
// here.
|
||||
|
@ -105,6 +101,7 @@ export default {
|
|||
computed: {
|
||||
...mapGetters({
|
||||
sortedWorkspaces: 'workspace/getAllSorted',
|
||||
workspaceInvitations: 'auth/getWorkspaceInvitations',
|
||||
}),
|
||||
...mapState({
|
||||
user: (state) => state.auth.user,
|
||||
|
@ -113,16 +110,6 @@ export default {
|
|||
}),
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* When a workspace invitation has been rejected or accepted, it can be removed from the
|
||||
* list because in both situations the invitation itself is deleted.
|
||||
*/
|
||||
removeInvitation(invitation) {
|
||||
const index = this.workspaceInvitations.findIndex(
|
||||
(i) => i.id === invitation.id
|
||||
)
|
||||
this.workspaceInvitations.splice(index, 1)
|
||||
},
|
||||
/**
|
||||
* Make sure that the selected workspace is visible.
|
||||
*/
|
||||
|
|
|
@ -33,6 +33,12 @@ import {
|
|||
MembersWorkspaceSettingsPageType,
|
||||
InvitesWorkspaceSettingsPageType,
|
||||
} from '@baserow/modules/core/workspaceSettingsPageTypes'
|
||||
import {
|
||||
WorkspaceInvitationCreatedNotificationType,
|
||||
WorkspaceInvitationAcceptedNotificationType,
|
||||
WorkspaceInvitationRejectedNotificationType,
|
||||
BaserowVersionUpgradeNotificationType,
|
||||
} from '@baserow/modules/core/notificationTypes'
|
||||
|
||||
import settingsStore from '@baserow/modules/core/store/settings'
|
||||
import applicationStore from '@baserow/modules/core/store/application'
|
||||
|
@ -44,6 +50,7 @@ import toastStore from '@baserow/modules/core/store/toast'
|
|||
import sidebarStore from '@baserow/modules/core/store/sidebar'
|
||||
import undoRedoStore from '@baserow/modules/core/store/undoRedo'
|
||||
import integrationStore from '@baserow/modules/core/store/integration'
|
||||
import notificationStore from '@baserow/modules/core/store/notification'
|
||||
|
||||
import en from '@baserow/modules/core/locales/en.json'
|
||||
import fr from '@baserow/modules/core/locales/fr.json'
|
||||
|
@ -87,6 +94,7 @@ export default (context, inject) => {
|
|||
registry.registerNamespace('userFileUpload')
|
||||
registry.registerNamespace('membersPagePlugins')
|
||||
registry.registerNamespace('runtime_formula_type')
|
||||
registry.registerNamespace('notification')
|
||||
registry.register('settings', new AccountSettingsType(context))
|
||||
registry.register('settings', new PasswordSettingsType(context))
|
||||
registry.register('settings', new DeleteAccountSettingsType(context))
|
||||
|
@ -129,6 +137,7 @@ export default (context, inject) => {
|
|||
|
||||
registry.registerNamespace('integration')
|
||||
registry.registerNamespace('service')
|
||||
store.registerModule('notification', notificationStore)
|
||||
|
||||
registry.register('authProvider', new PasswordAuthProviderType(context))
|
||||
registry.register('job', new DuplicateApplicationJobType(context))
|
||||
|
@ -148,4 +157,22 @@ export default (context, inject) => {
|
|||
registry.register('runtime_formula_type', new RuntimeConcat(context))
|
||||
registry.register('runtime_formula_type', new RuntimeGet(context))
|
||||
registry.register('runtime_formula_type', new RuntimeAdd(context))
|
||||
|
||||
// Notification types
|
||||
registry.register(
|
||||
'notification',
|
||||
new WorkspaceInvitationCreatedNotificationType(context)
|
||||
)
|
||||
registry.register(
|
||||
'notification',
|
||||
new WorkspaceInvitationAcceptedNotificationType(context)
|
||||
)
|
||||
registry.register(
|
||||
'notification',
|
||||
new WorkspaceInvitationRejectedNotificationType(context)
|
||||
)
|
||||
registry.register(
|
||||
'notification',
|
||||
new BaserowVersionUpgradeNotificationType(context)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -36,6 +36,10 @@ export class ClientErrorMap {
|
|||
app.i18n.t('clientHandler.rowDoesNotExistTitle'),
|
||||
app.i18n.t('clientHandler.rowDoesNotExistDescription')
|
||||
),
|
||||
ERROR_NOTIFICATION_DOES_NOT_EXIST: new ResponseErrorMessage(
|
||||
app.i18n.t('clientHandler.notificationDoesNotExistTitle'),
|
||||
app.i18n.t('clientHandler.notificationDoesNotExistDescription')
|
||||
),
|
||||
ERROR_FILE_SIZE_TOO_LARGE: new ResponseErrorMessage(
|
||||
app.i18n.t('clientHandler.fileSizeTooLargeTitle'),
|
||||
app.i18n.t('clientHandler.fileSizeTooLargeDescription')
|
||||
|
|
|
@ -316,6 +316,41 @@ export class RealTimeHandler {
|
|||
})
|
||||
}
|
||||
})
|
||||
|
||||
// invitations
|
||||
this.registerEvent('workspace_invitation_created', ({ store }, data) => {
|
||||
store.dispatch('auth/forceCreateWorkspaceInvitation', data.invitation)
|
||||
})
|
||||
|
||||
this.registerEvent('workspace_invitation_accepted', ({ store }, data) => {
|
||||
store.dispatch('auth/forceAcceptWorkspaceInvitation', data.invitation)
|
||||
})
|
||||
|
||||
this.registerEvent('workspace_invitation_rejected', ({ store }, data) => {
|
||||
store.dispatch('auth/forceRejectWorkspaceInvitation', data.invitation)
|
||||
})
|
||||
|
||||
// notifications
|
||||
|
||||
this.registerEvent('notifications_created', ({ store }, data) => {
|
||||
store.dispatch('notification/forceCreateInBulk', {
|
||||
notifications: data.notifications,
|
||||
})
|
||||
})
|
||||
|
||||
this.registerEvent('notification_marked_as_read', ({ store }, data) => {
|
||||
store.dispatch('notification/forceMarkAsRead', {
|
||||
notification: data.notification,
|
||||
})
|
||||
})
|
||||
|
||||
this.registerEvent('all_notifications_marked_as_read', ({ store }) => {
|
||||
store.dispatch('notification/forceMarkAllAsRead')
|
||||
})
|
||||
|
||||
this.registerEvent('all_notifications_cleared', ({ store }) => {
|
||||
store.dispatch('notification/forceClearAll')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
20
web-frontend/modules/core/services/notification.js
Normal file
20
web-frontend/modules/core/services/notification.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
export default (client) => {
|
||||
return {
|
||||
fetchAll(workspaceId, { offset = 0, limit = 50 }) {
|
||||
return client.get(
|
||||
`/notifications/${workspaceId}/?offset=${offset}&limit=${limit}`
|
||||
)
|
||||
},
|
||||
clearAll(workspaceId) {
|
||||
return client.delete(`/notifications/${workspaceId}/`)
|
||||
},
|
||||
markAllAsRead(workspaceId) {
|
||||
return client.post(`/notifications/${workspaceId}/mark-all-as-read/`)
|
||||
},
|
||||
markAsRead(workspaceId, notificationId) {
|
||||
return client.patch(`/notifications/${workspaceId}/${notificationId}/`, {
|
||||
read: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ import Vue from 'vue'
|
|||
import _ from 'lodash'
|
||||
|
||||
import AuthService from '@baserow/modules/core/services/auth'
|
||||
import WorkspaceService from '@baserow/modules/core/services/workspace'
|
||||
import { setToken, unsetToken } from '@baserow/modules/core/utils/auth'
|
||||
import { unsetWorkspaceCookie } from '@baserow/modules/core/utils/workspace'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
@ -23,6 +24,8 @@ export const state = () => ({
|
|||
preventSetToken: false,
|
||||
untrustedClientSessionId: uuidv4(),
|
||||
userSessionExpired: false,
|
||||
workspaceInvitations: [],
|
||||
umreadUserNotificationCount: 0,
|
||||
})
|
||||
|
||||
export const mutations = {
|
||||
|
@ -93,6 +96,20 @@ export const mutations = {
|
|||
SET_USER_SESSION_EXPIRED(state, expired) {
|
||||
state.userSessionExpired = expired
|
||||
},
|
||||
SET_WORKSPACE_INVIATIONS(state, invitations) {
|
||||
state.workspaceInvitations = invitations
|
||||
},
|
||||
ADD_WORKSPACE_INVITATION(state, invitation) {
|
||||
state.workspaceInvitations.push(invitation)
|
||||
},
|
||||
REMOVE_WORKSPACE_INVITATION(state, invitationId) {
|
||||
const existingIndex = state.workspaceInvitations.findIndex(
|
||||
(c) => c.id === invitationId
|
||||
)
|
||||
if (existingIndex !== -1) {
|
||||
state.workspaceInvitations.splice(existingIndex, 1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
@ -100,9 +117,9 @@ export const actions = {
|
|||
* Authenticate a user by his email and password. If successful commit the
|
||||
* token to the state and start the refresh timeout to stay authenticated.
|
||||
*/
|
||||
async login({ commit, getters }, { email, password }) {
|
||||
async login({ getters, dispatch }, { email, password }) {
|
||||
const { data } = await AuthService(this.$client).login(email, password)
|
||||
commit('SET_USER_DATA', data)
|
||||
dispatch('setUserData', data)
|
||||
|
||||
if (!getters.getPreventSetToken) {
|
||||
setToken(this.app, getters.refreshToken)
|
||||
|
@ -134,7 +151,7 @@ export const actions = {
|
|||
templateId
|
||||
)
|
||||
setToken(this.app, data.refresh_token)
|
||||
commit('SET_USER_DATA', data)
|
||||
dispatch('setUserData', data)
|
||||
},
|
||||
/**
|
||||
* Logs off the user by removing the token as a cookie and clearing the user
|
||||
|
@ -170,7 +187,7 @@ export const actions = {
|
|||
const { data } = await AuthService(this.$client).refresh(refreshToken)
|
||||
// if ROTATE_REFRESH_TOKEN=False in the backend the response will not contain
|
||||
// a new refresh token. In that case, we keep the one we just used.
|
||||
commit('SET_USER_DATA', {
|
||||
dispatch('setUserData', {
|
||||
refresh_token: refreshToken,
|
||||
tokenUpdatedAt,
|
||||
...data,
|
||||
|
@ -221,6 +238,14 @@ export const actions = {
|
|||
commit('UPDATE_USER_DATA', data)
|
||||
this.app.$bus.$emit('user-data-updated', data)
|
||||
},
|
||||
setUserData({ commit, dispatch }, data) {
|
||||
commit('SET_USER_DATA', data)
|
||||
dispatch(
|
||||
'notification/setUserUnreadCount',
|
||||
{ count: data.user_notifications?.unread_count },
|
||||
{ root: true }
|
||||
)
|
||||
},
|
||||
forceSetUserData({ commit }, data) {
|
||||
commit('SET_USER_DATA', data)
|
||||
},
|
||||
|
@ -232,6 +257,31 @@ export const actions = {
|
|||
unsetWorkspaceCookie(this.app)
|
||||
commit('SET_USER_SESSION_EXPIRED', value)
|
||||
},
|
||||
async fetchWorkspaceInvitations({ commit }) {
|
||||
const { data } = await AuthService(this.$client).dashboard()
|
||||
commit('SET_WORKSPACE_INVIATIONS', data.workspace_invitations)
|
||||
return data.workspace_invitations
|
||||
},
|
||||
forceCreateWorkspaceInvitation({ commit }, invitation) {
|
||||
commit('ADD_WORKSPACE_INVITATION', invitation)
|
||||
},
|
||||
async acceptWorkspaceInvitation({ commit }, invitationId) {
|
||||
const { data: workspace } = await WorkspaceService(
|
||||
this.$client
|
||||
).acceptInvitation(invitationId)
|
||||
commit('REMOVE_WORKSPACE_INVITATION', invitationId)
|
||||
return workspace
|
||||
},
|
||||
forceAcceptWorkspaceInvitation({ commit }, invitation) {
|
||||
commit('REMOVE_WORKSPACE_INVITATION', invitation.id)
|
||||
},
|
||||
async rejectWorkspaceInvitation({ commit }, invitationId) {
|
||||
await WorkspaceService(this.$client).rejectInvitation(invitationId)
|
||||
commit('REMOVE_WORKSPACE_INVITATION', invitationId)
|
||||
},
|
||||
forceRejectWorkspaceInvitation({ commit }, invitation) {
|
||||
commit('REMOVE_WORKSPACE_INVITATION', invitation.id)
|
||||
},
|
||||
}
|
||||
|
||||
export const getters = {
|
||||
|
@ -291,6 +341,9 @@ export const getters = {
|
|||
isUserSessionExpired: (state) => {
|
||||
return state.userSessionExpired
|
||||
},
|
||||
getWorkspaceInvitations(state) {
|
||||
return state.workspaceInvitations
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
|
|
351
web-frontend/modules/core/store/notification.js
Normal file
351
web-frontend/modules/core/store/notification.js
Normal file
|
@ -0,0 +1,351 @@
|
|||
import Vue from 'vue'
|
||||
import notificationService from '@baserow/modules/core/services/notification'
|
||||
|
||||
export const state = () => ({
|
||||
currentWorkspaceId: null,
|
||||
loading: false,
|
||||
loaded: false,
|
||||
userUnreadCount: 0,
|
||||
perWorkspaceUnreadCount: {},
|
||||
anyOtherWorkspaceWithUnread: false,
|
||||
totalCount: 0,
|
||||
currentCount: 0,
|
||||
items: [],
|
||||
})
|
||||
|
||||
function anyUnreadInOtherWorkspaces(state) {
|
||||
return Object.entries(state.perWorkspaceUnreadCount).some(
|
||||
([workspaceId, count]) =>
|
||||
state.currentWorkspaceId !== parseInt(workspaceId) && count > 0
|
||||
)
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
SET_WORKSPACE(state, workspace) {
|
||||
const workspaceChanged = state.currentWorkspaceId !== workspace.id
|
||||
state.currentWorkspaceId = workspace.id
|
||||
state.anyOtherWorkspaceWithUnread = anyUnreadInOtherWorkspaces(state)
|
||||
if (workspaceChanged) {
|
||||
state.loaded = false
|
||||
}
|
||||
},
|
||||
SET_USER_UNREAD_COUNT(state, count) {
|
||||
state.userUnreadCount = count || 0
|
||||
},
|
||||
SET(state, { notifications, totalCount = undefined }) {
|
||||
state.items = notifications
|
||||
state.currentCount = notifications.length
|
||||
state.totalCount = totalCount || notifications.length
|
||||
},
|
||||
ADD_NOTIFICATIONS(state, { notifications, totalCount }) {
|
||||
notifications.forEach((notification) => {
|
||||
const existingIndex = state.items.findIndex(
|
||||
(c) => c.id === notification.id
|
||||
)
|
||||
if (existingIndex >= 0) {
|
||||
// Prevent duplicates by just replacing them inline
|
||||
state.items.splice(existingIndex, 0, notification)
|
||||
} else {
|
||||
state.items.unshift(notification)
|
||||
}
|
||||
})
|
||||
state.currentCount = state.items.length
|
||||
state.totalCount = totalCount
|
||||
},
|
||||
SET_NOTIFICATIONS_READ(
|
||||
state,
|
||||
{ notificationIds, value, setUserCount, setWorkspaceCount }
|
||||
) {
|
||||
const updateCount = value
|
||||
? (curr, count = 1) => (curr > count ? curr - count : 0)
|
||||
: (curr, count = 1) => (curr || 0) + count
|
||||
|
||||
for (const item of state.items) {
|
||||
if (item.read === value || !notificationIds.includes(item.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
Vue.set(item, 'read', value)
|
||||
|
||||
const workspaceId = item.workspace?.id
|
||||
if (workspaceId) {
|
||||
const currCount = state.perWorkspaceUnreadCount[workspaceId] || 0
|
||||
Vue.set(
|
||||
state.perWorkspaceUnreadCount,
|
||||
workspaceId,
|
||||
updateCount(currCount)
|
||||
)
|
||||
} else {
|
||||
state.userUnreadCount = updateCount(state.userUnreadCount)
|
||||
}
|
||||
}
|
||||
|
||||
if (setUserCount !== undefined) {
|
||||
state.userUnreadCount = setUserCount
|
||||
}
|
||||
|
||||
if (setWorkspaceCount !== undefined) {
|
||||
Vue.set(
|
||||
state.perWorkspaceUnreadCount,
|
||||
state.currentWorkspaceId,
|
||||
setWorkspaceCount
|
||||
)
|
||||
}
|
||||
},
|
||||
SET_LOADING(state, loading) {
|
||||
state.loading = loading
|
||||
},
|
||||
SET_LOADED(state, loaded) {
|
||||
state.loaded = loaded
|
||||
},
|
||||
SET_TOTAL_COUNT(state, totalCount) {
|
||||
state.totalCount = totalCount
|
||||
},
|
||||
SET_PER_WORKSPACE_UNREAD_COUNT(state, perWorkspaceUnreadCount) {
|
||||
state.perWorkspaceUnreadCount = perWorkspaceUnreadCount
|
||||
state.anyOtherWorkspaceWithUnread = anyUnreadInOtherWorkspaces(state)
|
||||
},
|
||||
INCREMENT_WORKSPACE_UNREAD_COUNT(
|
||||
state,
|
||||
workspaceCount = { workspaceId: null, count: 1 }
|
||||
) {
|
||||
const { workspaceId, count } = workspaceCount
|
||||
if (!workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentCount = state.perWorkspaceUnreadCount[workspaceId] || 0
|
||||
Vue.set(state.perWorkspaceUnreadCount, workspaceId, currentCount + count)
|
||||
state.anyOtherWorkspaceWithUnread = anyUnreadInOtherWorkspaces(state)
|
||||
},
|
||||
SET_WORKSPACE_UNREAD_COUNT(state, { workspaceId, count }) {
|
||||
Vue.set(state.perWorkspaceUnreadCount, workspaceId, count)
|
||||
state.anyOtherWorkspaceWithUnread = anyUnreadInOtherWorkspaces(state)
|
||||
},
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
/**
|
||||
* Fetches the next 20 notifications from the server and adds them to the comments list.
|
||||
*/
|
||||
async fetchNextSetOfNotifications({ commit, state }) {
|
||||
commit('SET_LOADING', true)
|
||||
try {
|
||||
// We have to use offset based paging here as new notifications can be added by the
|
||||
// user or come in via realtime events.
|
||||
const { data } = await notificationService(this.$client).fetchAll(
|
||||
state.currentWorkspaceId,
|
||||
{ offset: state.currentCount }
|
||||
)
|
||||
commit('ADD_NOTIFICATIONS', {
|
||||
notifications: data.results,
|
||||
totalCount: data.count,
|
||||
})
|
||||
} finally {
|
||||
commit('SET_LOADING', false)
|
||||
}
|
||||
},
|
||||
async fetchAll({ commit, state }, { workspaceId }) {
|
||||
commit('SET_LOADING', true)
|
||||
commit('SET_LOADED', false)
|
||||
try {
|
||||
const { data } = await notificationService(this.$client).fetchAll(
|
||||
workspaceId,
|
||||
{}
|
||||
)
|
||||
commit('SET', { notifications: data.results, totalCount: data.count })
|
||||
commit('SET_LOADED', true)
|
||||
} catch (error) {
|
||||
commit('SET', { notifications: [] })
|
||||
throw error
|
||||
} finally {
|
||||
commit('SET_LOADING', false)
|
||||
}
|
||||
return state.items
|
||||
},
|
||||
async clearAll({ commit, state }) {
|
||||
const notifications = state.items
|
||||
const totalCount = state.totalCount
|
||||
const prevUserCount = state.userUnreadCount
|
||||
const prevWorkspaceCount =
|
||||
state.perWorkspaceUnreadCount[state.currentWorkspaceId]
|
||||
commit('SET', { notifications: [] })
|
||||
commit('SET_WORKSPACE_UNREAD_COUNT', {
|
||||
workspaceId: state.currentWorkspaceId,
|
||||
count: 0,
|
||||
})
|
||||
commit('SET_USER_UNREAD_COUNT', 0)
|
||||
try {
|
||||
await notificationService(this.$client).clearAll(state.currentWorkspaceId)
|
||||
} catch (error) {
|
||||
commit('SET', { notifications, totalCount })
|
||||
commit('SET_WORKSPACE_UNREAD_COUNT', {
|
||||
workspaceId: state.currentWorkspaceId,
|
||||
count: prevWorkspaceCount,
|
||||
})
|
||||
commit('SET_USER_UNREAD_COUNT', prevUserCount)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
forceClearAll({ commit, state }) {
|
||||
commit('SET', { notifications: [] })
|
||||
commit('SET_WORKSPACE_UNREAD_COUNT', state.currentWorkspaceId)
|
||||
commit('SET_USER_UNREAD_COUNT', 0)
|
||||
},
|
||||
async markAsRead({ commit, state }, { notification }) {
|
||||
commit('SET_NOTIFICATIONS_READ', {
|
||||
notificationIds: [notification.id],
|
||||
value: true,
|
||||
})
|
||||
try {
|
||||
await notificationService(this.$client).markAsRead(
|
||||
state.currentWorkspaceId,
|
||||
notification.id
|
||||
)
|
||||
} catch (error) {
|
||||
commit('SET_NOTIFICATIONS_READ', {
|
||||
notificationIds: [notification.id],
|
||||
value: false,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
forceMarkAsRead({ commit, state }, { notification }) {
|
||||
commit('SET_NOTIFICATIONS_READ', {
|
||||
notificationIds: [notification.id],
|
||||
value: true,
|
||||
})
|
||||
},
|
||||
async markAllAsRead({ commit, state }) {
|
||||
const notificationIds = state.items
|
||||
.filter((notification) => !notification.read)
|
||||
.map((notification) => notification.id)
|
||||
|
||||
const prevUserCount = state.userUnreadCount
|
||||
const prevWorkspaceCount =
|
||||
state.perWorkspaceUnreadCount[state.currentWorkspaceId]
|
||||
|
||||
commit('SET_NOTIFICATIONS_READ', {
|
||||
notificationIds,
|
||||
value: true,
|
||||
setUserCount: 0,
|
||||
setWorkspaceCount: 0,
|
||||
})
|
||||
try {
|
||||
await notificationService(this.$client).markAllAsRead(
|
||||
state.currentWorkspaceId
|
||||
)
|
||||
} catch (error) {
|
||||
commit('SET_NOTIFICATIONS_READ', {
|
||||
notificationIds,
|
||||
value: false,
|
||||
setUserCount: prevUserCount,
|
||||
setWorkspaceCount: prevWorkspaceCount,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
forceMarkAllAsRead({ commit, state }) {
|
||||
const notificationIds = state.items
|
||||
.filter((notification) => !notification.read)
|
||||
.map((notification) => notification.id)
|
||||
commit('SET_NOTIFICATIONS_READ', {
|
||||
notificationIds,
|
||||
value: true,
|
||||
setUserCount: 0,
|
||||
setWorkspaceCount: 0,
|
||||
})
|
||||
},
|
||||
forceCreateInBulk({ commit, state }, { notifications }) {
|
||||
const unreadCountPerWorkspace = notifications.reduce(
|
||||
(acc, notification) => {
|
||||
if (!notification.read) {
|
||||
const workspaceId = notification.workspace?.id || 'null'
|
||||
acc[workspaceId] = acc[workspaceId] ? acc[workspaceId] + 1 : 1
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
for (const [workspaceId, count] of Object.entries(
|
||||
unreadCountPerWorkspace
|
||||
)) {
|
||||
if (workspaceId !== 'null') {
|
||||
commit('INCREMENT_WORKSPACE_UNREAD_COUNT', {
|
||||
workspaceId: parseInt(workspaceId),
|
||||
count,
|
||||
})
|
||||
} else {
|
||||
commit('SET_USER_UNREAD_COUNT', state.userUnreadCount + count)
|
||||
}
|
||||
}
|
||||
|
||||
const visibleNotifications = notifications.filter(
|
||||
(n) => !n.workspace?.id || n.workspace?.id === state.currentWorkspaceId
|
||||
)
|
||||
if (visibleNotifications.length > 0) {
|
||||
commit('ADD_NOTIFICATIONS', {
|
||||
notifications: visibleNotifications,
|
||||
totalCount: state.totalCount + visibleNotifications.length,
|
||||
})
|
||||
}
|
||||
},
|
||||
setWorkspace({ commit }, { workspace }) {
|
||||
commit('SET_WORKSPACE', workspace)
|
||||
},
|
||||
setPerWorkspaceUnreadCount({ commit }, { workspaces }) {
|
||||
commit(
|
||||
'SET_PER_WORKSPACE_UNREAD_COUNT',
|
||||
Object.fromEntries(
|
||||
workspaces.map((wp) => [wp.id, wp.unread_notifications_count])
|
||||
)
|
||||
)
|
||||
},
|
||||
setUserUnreadCount({ commit }, { count }) {
|
||||
commit('SET_USER_UNREAD_COUNT', count)
|
||||
},
|
||||
}
|
||||
|
||||
export const getters = {
|
||||
getWorkspaceId(state) {
|
||||
return state.currentWorkspaceId
|
||||
},
|
||||
getAll(state) {
|
||||
return state.items
|
||||
},
|
||||
getUnreadCount(state) {
|
||||
const workspaceCount =
|
||||
state.perWorkspaceUnreadCount[state.currentWorkspaceId] || 0
|
||||
return state.userUnreadCount + workspaceCount
|
||||
},
|
||||
getCurrentCount(state) {
|
||||
return state.currentCount
|
||||
},
|
||||
getTotalCount(state) {
|
||||
return state.totalCount
|
||||
},
|
||||
getLoading(state) {
|
||||
return state.loading
|
||||
},
|
||||
getLoaded(state) {
|
||||
return state.loaded
|
||||
},
|
||||
userHasUnread(state) {
|
||||
return state.userUnreadCount > 0
|
||||
},
|
||||
workspaceHasUnread: (state) => (workspaceId) => {
|
||||
return (state.perWorkspaceUnreadCount[workspaceId] || 0) > 0
|
||||
},
|
||||
anyOtherWorkspaceWithUnread(state) {
|
||||
return state.anyOtherWorkspaceWithUnread
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
}
|
|
@ -197,7 +197,7 @@ export const actions = {
|
|||
/**
|
||||
* Fetches all the workspaces of an authenticated user.
|
||||
*/
|
||||
async fetchAll({ commit }) {
|
||||
async fetchAll({ commit, dispatch, state }) {
|
||||
commit('SET_LOADING', true)
|
||||
|
||||
try {
|
||||
|
@ -207,8 +207,17 @@ export const actions = {
|
|||
} catch {
|
||||
commit('SET_ITEMS', [])
|
||||
}
|
||||
|
||||
commit('SET_LOADING', false)
|
||||
|
||||
if (state.items.length > 0) {
|
||||
// Every workspace contains an unread notifications count for the user,
|
||||
// so let's update that.
|
||||
dispatch(
|
||||
'notification/setPerWorkspaceUnreadCount',
|
||||
{ workspaces: state.items },
|
||||
{ root: true }
|
||||
)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Creates a new workspace with the given values.
|
||||
|
@ -360,6 +369,7 @@ export const actions = {
|
|||
root: true,
|
||||
}
|
||||
)
|
||||
dispatch('notification/setWorkspace', { workspace }, { root: true })
|
||||
return workspace
|
||||
},
|
||||
/**
|
||||
|
|
42
web-frontend/modules/database/utils/router.js
Normal file
42
web-frontend/modules/database/utils/router.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
|
||||
export async function openRowEditModal(
|
||||
{ $store, $router, $route },
|
||||
{ databaseId, tableId, rowId }
|
||||
) {
|
||||
const tableRoute = $route.name.startsWith('database-table')
|
||||
const sameTable = tableRoute && $route.params.tableId === tableId
|
||||
|
||||
// Because 'rowModalNavigation/fetchRow' is called in the asyncData, we need
|
||||
// to manually call it here if we are already on the row/table page.
|
||||
if (sameTable) {
|
||||
try {
|
||||
await $store.dispatch('rowModalNavigation/fetchRow', {
|
||||
tableId,
|
||||
rowId,
|
||||
})
|
||||
} catch (error) {
|
||||
notifyIf(error, 'application')
|
||||
return
|
||||
}
|
||||
const newPath = $router.resolve({
|
||||
name: 'database-table-row',
|
||||
params: {
|
||||
databaseId,
|
||||
tableId,
|
||||
rowId,
|
||||
viewId: $route.params.viewId,
|
||||
},
|
||||
}).href
|
||||
history.replaceState({}, null, newPath)
|
||||
} else {
|
||||
$router.push({
|
||||
name: 'database-table-row',
|
||||
params: {
|
||||
databaseId,
|
||||
tableId,
|
||||
rowId,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
217
web-frontend/test/unit/core/store/notification.spec.js
Normal file
217
web-frontend/test/unit/core/store/notification.spec.js
Normal file
|
@ -0,0 +1,217 @@
|
|||
import { TestApp } from '@baserow/test/helpers/testApp'
|
||||
|
||||
describe('Notification store', () => {
|
||||
let testApp = null
|
||||
let store = null
|
||||
|
||||
beforeEach(() => {
|
||||
testApp = new TestApp()
|
||||
store = testApp.store
|
||||
store.dispatch('notification/setWorkspace', { workspace: { id: 1 } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
testApp.afterEach()
|
||||
})
|
||||
|
||||
test('can add an unread notification to current workspace increasing the unread counter', () => {
|
||||
store.dispatch('notification/forceCreateInBulk', {
|
||||
notifications: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'test',
|
||||
workspace: { id: 1 },
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'test 2',
|
||||
workspace: { id: 1 },
|
||||
read: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
const notifications = store.getters['notification/getAll']
|
||||
expect(JSON.parse(JSON.stringify(notifications))).toStrictEqual([
|
||||
{
|
||||
id: 2,
|
||||
type: 'test 2',
|
||||
workspace: { id: 1 },
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
type: 'test',
|
||||
workspace: { id: 1 },
|
||||
read: false,
|
||||
},
|
||||
])
|
||||
expect(store.getters['notification/getUnreadCount']).toBe(2)
|
||||
expect(store.getters['notification/anyOtherWorkspaceWithUnread']).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('can add an already read notification to current workspace without increasing the unread counter', () => {
|
||||
store.dispatch('notification/forceCreateInBulk', {
|
||||
notifications: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'test',
|
||||
workspace: { id: 1 },
|
||||
read: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
const notifications = store.getters['notification/getAll']
|
||||
expect(JSON.parse(JSON.stringify(notifications))).toStrictEqual([
|
||||
{
|
||||
id: 1,
|
||||
type: 'test',
|
||||
workspace: { id: 1 },
|
||||
read: true,
|
||||
},
|
||||
])
|
||||
expect(store.getters['notification/getUnreadCount']).toBe(0)
|
||||
expect(store.getters['notification/anyOtherWorkspaceWithUnread']).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('can add a notification to other workspace increasing relative unread counter', () => {
|
||||
store.dispatch('notification/forceCreateInBulk', {
|
||||
notifications: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'test',
|
||||
workspace: { id: 999 },
|
||||
read: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
const notifications = store.getters['notification/getAll']
|
||||
expect(JSON.parse(JSON.stringify(notifications))).toStrictEqual([])
|
||||
expect(store.getters['notification/getUnreadCount']).toBe(0)
|
||||
expect(store.getters['notification/workspaceHasUnread'](999)).toBe(true)
|
||||
expect(store.getters['notification/anyOtherWorkspaceWithUnread']).toBe(true)
|
||||
})
|
||||
|
||||
test('can mark a notification as read', () => {
|
||||
store.dispatch('notification/forceCreateInBulk', {
|
||||
notifications: [
|
||||
{
|
||||
id: 5,
|
||||
type: 'test',
|
||||
workspace: { id: 1 },
|
||||
read: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
store.dispatch('notification/forceMarkAsRead', { notification: { id: 5 } })
|
||||
|
||||
const notifications = store.getters['notification/getAll']
|
||||
expect(JSON.parse(JSON.stringify(notifications))).toStrictEqual([
|
||||
{
|
||||
id: 5,
|
||||
type: 'test',
|
||||
workspace: { id: 1 },
|
||||
read: true,
|
||||
},
|
||||
])
|
||||
|
||||
expect(store.getters['notification/getUnreadCount']).toBe(0)
|
||||
})
|
||||
|
||||
test('can mark all notifications as read', () => {
|
||||
store.commit('notification/SET', {
|
||||
notifications: [
|
||||
{
|
||||
id: 5,
|
||||
type: 'test',
|
||||
workspace: { id: 1 },
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
type: 'test 2',
|
||||
workspace: { id: 1 },
|
||||
read: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
store.commit('notification/SET_USER_UNREAD_COUNT', 999)
|
||||
expect(store.getters['notification/getUnreadCount']).toBe(999)
|
||||
|
||||
store.dispatch('notification/forceMarkAllAsRead')
|
||||
|
||||
const notifications = store.getters['notification/getAll']
|
||||
expect(JSON.parse(JSON.stringify(notifications))).toStrictEqual([
|
||||
{
|
||||
id: 5,
|
||||
type: 'test',
|
||||
workspace: { id: 1 },
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
type: 'test 2',
|
||||
workspace: { id: 1 },
|
||||
read: true,
|
||||
},
|
||||
])
|
||||
expect(store.getters['notification/getUnreadCount']).toBe(0)
|
||||
})
|
||||
|
||||
test('can clear all notifications', () => {
|
||||
store.commit('notification/SET', {
|
||||
notifications: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'test',
|
||||
workspace: { id: 1 },
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'test',
|
||||
workspace: { id: 1 },
|
||||
read: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
store.dispatch('notification/forceClearAll')
|
||||
|
||||
const notifications = store.getters['notification/getAll']
|
||||
expect(JSON.parse(JSON.stringify(notifications))).toStrictEqual([])
|
||||
expect(store.getters['notification/getUnreadCount']).toBe(0)
|
||||
})
|
||||
|
||||
test('getting user data set the user unread count', () => {
|
||||
const fakeUserData = {
|
||||
user: {
|
||||
id: 256,
|
||||
},
|
||||
access_token:
|
||||
`eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImpvaG5AZXhhb` +
|
||||
`XBsZS5jb20iLCJpYXQiOjE2NjAyOTEwODYsImV4cCI6MTY2MDI5NDY4NiwianRpIjo` +
|
||||
`iNDZmNzUwZWUtMTJhMS00N2UzLWJiNzQtMDIwYWM4Njg3YWMzIiwidXNlcl9pZCI6M` +
|
||||
`iwidXNlcl9wcm9maWxlX2lkIjpbMl0sIm9yaWdfaWF0IjoxNjYwMjkxMDg2fQ.RQ-M` +
|
||||
`NQdDR9zTi8CbbQkRrwNsyDa5CldQI83Uid1l9So`,
|
||||
user_notifications: { unread_count: 1 },
|
||||
}
|
||||
store.dispatch('auth/setUserData', fakeUserData)
|
||||
expect(store.getters['notification/getUnreadCount']).toBe(1)
|
||||
})
|
||||
|
||||
test('fetching workspaces set the unread count', () => {
|
||||
store.dispatch('notification/setPerWorkspaceUnreadCount', {
|
||||
workspaces: [
|
||||
{ id: 1, unread_notifications_count: 1 },
|
||||
{ id: 2, unread_notifications_count: 2 },
|
||||
],
|
||||
})
|
||||
expect(store.getters['notification/getUnreadCount']).toBe(1)
|
||||
expect(store.getters['notification/workspaceHasUnread'](2)).toBe(true)
|
||||
})
|
||||
})
|
Loading…
Add table
Reference in a new issue