1
0
Fork 0
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:
Davide Silvestri 2023-07-14 07:59:25 +00:00
parent df49a243e0
commit c5c79744ce
85 changed files with 4776 additions and 84 deletions
backend
changelog/entries/unreleased/feature
enterprise/backend/tests/baserow_enterprise_tests/role
premium
backend
web-frontend/modules/baserow_premium
web-frontend

View 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.",
)

View 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},
}

View 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",
),
]

View 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}

View 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)

View file

@ -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):

View file

@ -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

View file

@ -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):

View file

@ -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
)

View file

@ -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,

View file

@ -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.

View file

@ -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

View 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"
),
),
]

View file

@ -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",
]

View 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},
)

View file

@ -0,0 +1,5 @@
from .models import Notification
class NotificationDoesNotExist(Notification.DoesNotExist):
pass

View 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

View 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"],
),
]

View 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"

View 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),
)
)

View 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()

View 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)

View 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()

View file

@ -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

View file

@ -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]:

View file

@ -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()

View file

@ -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

View 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

View file

@ -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:

View file

@ -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,
},
)
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -372,7 +372,6 @@ def test_get_permissions(data_fixture):
)
result = CoreHandler().get_permissions(admin)
print(result)
assert result == [
{"name": "view_ownership", "permissions": {}},

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Create a notification panel to show notifications",
"issue_number": 1775,
"bullet_points": [],
"created_at": "2023-06-30"
}

View file

@ -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()

View file

@ -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())

View file

@ -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,
)

View file

@ -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)

View file

@ -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]
)

View file

@ -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",
{},

View file

@ -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

View file

@ -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,
},
}
],
}

View file

@ -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;

View file

@ -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()

View file

@ -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>

View file

@ -53,6 +53,9 @@
"edit": "Edit comment",
"delete": "Delete comment"
},
"rowCommentMentionNotification": {
"title": "{sender} mentioned you in {table}"
},
"trashType": {
"row_comment": "row comment"
},

View file

@ -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
}
}

View file

@ -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)
)
}

View file

@ -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",

View file

@ -123,3 +123,4 @@
@import 'thumbnail';
@import 'integrations/all';
@import 'editable';
@import 'notification_panel';

View file

@ -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;
}

View file

@ -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 {

View file

@ -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;
}

View file

@ -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;
}

View 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>

View file

@ -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) {

View file

@ -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>

View file

@ -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>

View file

@ -0,0 +1,14 @@
<template>
<img :src="icon" />
</template>
<script>
export default {
props: {
icon: {
type: String,
required: true,
},
},
}
</script>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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: {

View file

@ -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')

View file

@ -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>

View file

@ -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
)
},

View file

@ -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."
}
}

View 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')
}
},
},
}

View 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
}
}

View file

@ -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.
*/

View file

@ -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)
)
}

View file

@ -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')

View file

@ -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')
})
}
}

View 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,
})
},
}
}

View file

@ -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 {

View 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,
}

View file

@ -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
},
/**

View 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,
},
})
}
}

View 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)
})
})