diff --git a/.gitignore b/.gitignore index a391fafbf..e1ee2d011 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,5 @@ junit.xml !plugin-boilerplate/{{ cookiecutter.project_slug }}/.env field-diagrams/ + +*.http \ No newline at end of file diff --git a/backend/src/baserow/api/groups/invitations/views.py b/backend/src/baserow/api/groups/invitations/views.py index 01e1e57ae..90143b43d 100644 --- a/backend/src/baserow/api/groups/invitations/views.py +++ b/backend/src/baserow/api/groups/invitations/views.py @@ -324,7 +324,10 @@ class AcceptGroupInvitationView(APIView): group_user = CoreHandler().accept_group_invitation( request.user, group_invitation ) - return Response(GroupUserGroupSerializer(group_user).data) + groupuser_group = ( + CoreHandler().get_groupuser_group_queryset().get(id=group_user.id) + ) + return Response(GroupUserGroupSerializer(groupuser_group).data) class RejectGroupInvitationView(APIView): diff --git a/backend/src/baserow/api/groups/schemas.py b/backend/src/baserow/api/groups/schemas.py deleted file mode 100644 index 05cd40481..000000000 --- a/backend/src/baserow/api/groups/schemas.py +++ /dev/null @@ -1,21 +0,0 @@ -from drf_spectacular.plumbing import build_object_type - -group_user_schema = build_object_type( - { - "order": { - "type": "integer", - "description": "The order of the group, lowest first.", - "example": 0, - }, - "id": { - "type": "integer", - "description": "The unique identifier of the group.", - "example": 1, - }, - "name": { - "type": "string", - "description": "The name given to the group.", - "example": "Bram's group", - }, - } -) diff --git a/backend/src/baserow/api/groups/serializers.py b/backend/src/baserow/api/groups/serializers.py index 603ea584a..1ca2c03b9 100644 --- a/backend/src/baserow/api/groups/serializers.py +++ b/backend/src/baserow/api/groups/serializers.py @@ -2,9 +2,14 @@ from rest_framework import serializers from baserow.core.models import Group -from .users.serializers import GroupUserGroupSerializer +from .users.serializers import GroupUserGroupSerializer, GroupUserSerializer -__all__ = ["GroupUserGroupSerializer", "GroupSerializer", "OrderGroupsSerializer"] +__all__ = [ + "GroupUserGroupSerializer", + "GroupSerializer", + "OrderGroupsSerializer", + "GroupUserSerializer", +] class GroupSerializer(serializers.ModelSerializer): diff --git a/backend/src/baserow/api/groups/users/serializers.py b/backend/src/baserow/api/groups/users/serializers.py index 154365b90..cb3b9e304 100644 --- a/backend/src/baserow/api/groups/users/serializers.py +++ b/backend/src/baserow/api/groups/users/serializers.py @@ -39,22 +39,36 @@ class GroupUserSerializer(serializers.ModelSerializer): return object.user.email -class GroupUserGroupSerializer(serializers.ModelSerializer): +class GroupUserGroupSerializer(serializers.Serializer): """ - This serializers returns all the fields that the GroupSerializer has, but also - some user specific values related to the group user relation. + This serializers includes relevant fields of the Group model, but also + some GroupUser specific fields related to the group user relation. + + Additionally, the list of users are included for each group. """ - class Meta: - model = GroupUser - fields = ("order", "permissions") + # Group fields + id = serializers.IntegerField( + source="group.id", read_only=True, help_text="Group id." + ) + name = serializers.CharField( + source="group.name", read_only=True, help_text="Group name." + ) + users = GroupUserSerializer( + many=True, + source="group.groupuser_set", + required=False, + read_only=True, + help_text="List of all group users.", + ) - def to_representation(self, instance): - from baserow.api.groups.serializers import GroupSerializer - - data = super().to_representation(instance) - data.update(GroupSerializer(instance.group).data) - return data + # GroupUser fields + order = serializers.IntegerField( + read_only=True, help_text="The requesting user's order within the group users." + ) + permissions = serializers.CharField( + read_only=True, help_text="The requesting user's permissions for the group." + ) class UpdateGroupUserSerializer(serializers.ModelSerializer): diff --git a/backend/src/baserow/api/groups/users/views.py b/backend/src/baserow/api/groups/users/views.py index 4ffa42a0e..b495ebc9a 100644 --- a/backend/src/baserow/api/groups/users/views.py +++ b/backend/src/baserow/api/groups/users/views.py @@ -23,11 +23,7 @@ from baserow.core.exceptions import ( from baserow.core.handler import CoreHandler from baserow.core.models import GroupUser -from .serializers import ( - GroupUserGroupSerializer, - GroupUserSerializer, - UpdateGroupUserSerializer, -) +from .serializers import GroupUserSerializer, UpdateGroupUserSerializer class GroupUsersView(APIView): @@ -95,7 +91,7 @@ class GroupUserView(APIView): ), request=UpdateGroupUserSerializer, responses={ - 200: GroupUserGroupSerializer, + 200: GroupUserSerializer, 400: get_error_schema( [ "ERROR_USER_NOT_IN_GROUP", @@ -123,7 +119,7 @@ class GroupUserView(APIView): base_queryset=GroupUser.objects.select_for_update(of=("self",)), ) group_user = CoreHandler().update_group_user(request.user, group_user, **data) - return Response(GroupUserGroupSerializer(group_user).data) + return Response(GroupUserSerializer(group_user).data) @extend_schema( parameters=[ diff --git a/backend/src/baserow/api/groups/views.py b/backend/src/baserow/api/groups/views.py index ec59029d6..9750bf91b 100644 --- a/backend/src/baserow/api/groups/views.py +++ b/backend/src/baserow/api/groups/views.py @@ -1,7 +1,6 @@ from django.db import transaction from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes -from drf_spectacular.plumbing import build_array_type from drf_spectacular.utils import extend_schema from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -34,11 +33,9 @@ from baserow.core.exceptions import ( UserNotInGroup, ) from baserow.core.handler import CoreHandler -from baserow.core.models import GroupUser from baserow.core.trash.exceptions import CannotDeleteAlreadyDeletedItem from .errors import ERROR_GROUP_USER_IS_LAST_ADMIN -from .schemas import group_user_schema from .serializers import GroupSerializer, OrderGroupsSerializer @@ -56,13 +53,15 @@ class GroupsView(APIView): "are custom for each user. The order is configurable via the " "**order_groups** endpoint." ), - responses={200: build_array_type(group_user_schema)}, + responses={200: GroupUserGroupSerializer(many=True)}, ) def get(self, request): """Responds with a list of serialized groups where the user is part of.""" - groups = GroupUser.objects.filter(user=request.user).select_related("group") - serializer = GroupUserGroupSerializer(groups, many=True) + groupuser_groups = ( + CoreHandler().get_groupuser_group_queryset().filter(user=request.user) + ) + serializer = GroupUserGroupSerializer(groupuser_groups, many=True) return Response(serializer.data) @extend_schema( @@ -75,7 +74,7 @@ class GroupsView(APIView): "created via other endpoints." ), request=GroupSerializer, - responses={200: group_user_schema}, + responses={200: GroupUserGroupSerializer}, ) @transaction.atomic @validate_body(GroupSerializer) @@ -160,7 +159,7 @@ class GroupView(APIView): ), request=GroupSerializer, responses={ - 200: group_user_schema, + 204: None, 400: get_error_schema( [ "ERROR_USER_NOT_IN_GROUP", diff --git a/backend/src/baserow/api/user/serializers.py b/backend/src/baserow/api/user/serializers.py index 2f2f8ab1c..115d8bb01 100644 --- a/backend/src/baserow/api/user/serializers.py +++ b/backend/src/baserow/api/user/serializers.py @@ -48,6 +48,20 @@ class UserSerializer(serializers.ModelSerializer): } +class PublicUserSerializer(serializers.ModelSerializer): + """ + Serializer that exposes only fields that can be shared + about the user for the whole group. + """ + + class Meta: + model = User + fields = ("id", "username", "first_name") + extra_kwargs = { + "id": {"read_only": True}, + } + + class RegisterSerializer(serializers.Serializer): name = serializers.CharField(min_length=2, max_length=150) email = serializers.EmailField( diff --git a/backend/src/baserow/core/handler.py b/backend/src/baserow/core/handler.py index 59431e22f..b9b176c8e 100644 --- a/backend/src/baserow/core/handler.py +++ b/backend/src/baserow/core/handler.py @@ -12,7 +12,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser from django.core.files.storage import default_storage from django.db import transaction -from django.db.models import Count, Q, QuerySet +from django.db.models import Count, Prefetch, Q, QuerySet from django.utils import translation from itsdangerous import URLSafeSerializer @@ -153,6 +153,22 @@ class CoreHandler: return group + def get_groupuser_group_queryset(self) -> QuerySet[GroupUser]: + """ + Returns GroupUser queryset that will prefetch groups and their users. + """ + + groupusers_with_user_and_profile = GroupUser.objects.select_related( + "user" + ).select_related("user__profile") + groupuser_groups = GroupUser.objects.select_related("group").prefetch_related( + Prefetch( + "group__groupuser_set", + queryset=groupusers_with_user_and_profile, + ) + ) + return groupuser_groups + def create_group(self, user: User, name: str) -> GroupUser: """ Creates a new group for an existing user. @@ -243,7 +259,10 @@ class CoreHandler: group_user_id = group_user.id group_user.delete() group_user_deleted.send( - self, group_user_id=group_user_id, group_user=group_user, user=user + self, + group_user_id=group_user_id, + group_user=group_user, + user=user, ) def delete_group_by_id(self, user: AbstractUser, group_id: int): @@ -394,7 +413,10 @@ class CoreHandler: group_user.delete() group_user_deleted.send( - self, group_user_id=group_user_id, group_user=group_user, user=user + self, + group_user_id=group_user_id, + group_user=group_user, + user=user, ) def get_group_invitation_signer(self): diff --git a/backend/src/baserow/core/signals.py b/backend/src/baserow/core/signals.py index 86d976c79..ecf394bd3 100644 --- a/backend/src/baserow/core/signals.py +++ b/backend/src/baserow/core/signals.py @@ -6,6 +6,11 @@ before_user_deleted = Signal() before_group_deleted = Signal() +user_updated = Signal() +user_deleted = Signal() +user_restored = Signal() +user_permanently_deleted = Signal() + group_created = Signal() group_updated = Signal() group_deleted = Signal() diff --git a/backend/src/baserow/core/user/handler.py b/backend/src/baserow/core/user/handler.py index ec8a51339..bae7fc24b 100644 --- a/backend/src/baserow/core/user/handler.py +++ b/backend/src/baserow/core/user/handler.py @@ -19,9 +19,15 @@ from baserow.core.exceptions import ( GroupInvitationEmailMismatch, ) from baserow.core.handler import CoreHandler -from baserow.core.models import Group, Template, UserLogEntry, UserProfile +from baserow.core.models import Group, GroupUser, Template, UserLogEntry, UserProfile from baserow.core.registries import plugin_registry -from baserow.core.signals import before_user_deleted +from baserow.core.signals import ( + before_user_deleted, + user_deleted, + user_permanently_deleted, + user_restored, + user_updated, +) from baserow.core.trash.handler import TrashHandler from .emails import ( @@ -189,7 +195,8 @@ class UserHandler: language: Optional[str] = None, ) -> AbstractUser: """ - Updates the user's account editable properties + Updates the user's account editable properties. Handles the scenario + when a user edits his own account. :param user: The user instance to update. :param first_name: The new user first name. @@ -205,6 +212,8 @@ class UserHandler: user.profile.language = language user.profile.save() + user_updated.send(self, performed_by=user, user=user) + return user def get_reset_password_signer(self) -> URLSafeTimedSerializer: @@ -374,6 +383,8 @@ class UserHandler: email = AccountDeletionScheduled(user, days_left, to=[user.email]) email.send() + user_deleted.send(self, performed_by=user, user=user) + def cancel_user_deletion(self, user: AbstractUser): """ Cancels a previously scheduled user account deletion. This action send an email @@ -389,6 +400,8 @@ class UserHandler: email = AccountDeletionCanceled(user, to=[user.email]) email.send() + user_restored.send(self, performed_by=user, user=user) + def delete_expired_users(self, grace_delay: Optional[timedelta] = None): """ Executes all previously scheduled user account deletions for which @@ -416,9 +429,14 @@ class UserHandler: profile__to_be_deleted=True, last_login__lt=limit_date ) - deleted_user_info = [ - (u.username, u.email, u.profile.language) for u in users_to_delete.all() - ] + group_users = GroupUser.objects.filter(user__in=users_to_delete) + + deleted_user_info = [] + for u in users_to_delete.all(): + group_ids = [gu.group_id for gu in group_users if gu.user_id == u.id] + deleted_user_info.append( + (u.id, u.username, u.email, u.profile.language, group_ids) + ) # A group need to be deleted if there was an admin before and there is no # *active* admin after the users deletion. @@ -447,7 +465,8 @@ class UserHandler: TrashHandler.permanently_delete(group) users_to_delete.delete() - for (username, email, language) in deleted_user_info: + for (id, username, email, language, group_ids) in deleted_user_info: with translation.override(language): email = AccountDeleted(username, to=[email]) email.send() + user_permanently_deleted.send(self, user_id=id, group_ids=group_ids) diff --git a/backend/src/baserow/ws/signals.py b/backend/src/baserow/ws/signals.py index 7f1331285..edf0e5390 100644 --- a/backend/src/baserow/ws/signals.py +++ b/backend/src/baserow/ws/signals.py @@ -2,10 +2,68 @@ from django.db import transaction from django.dispatch import receiver from baserow.api.applications.serializers import get_application_serializer -from baserow.api.groups.serializers import GroupSerializer, GroupUserGroupSerializer +from baserow.api.groups.serializers import ( + GroupSerializer, + GroupUserGroupSerializer, + GroupUserSerializer, +) +from baserow.api.user.serializers import PublicUserSerializer from baserow.core import signals +from baserow.core.handler import CoreHandler +from baserow.core.models import GroupUser -from .tasks import broadcast_to_group, broadcast_to_users +from .tasks import broadcast_to_group, broadcast_to_groups, broadcast_to_users + + +@receiver(signals.user_updated) +def user_updated(sender, performed_by, user, **kwargs): + group_ids = list( + GroupUser.objects.filter(user=user).values_list("group_id", flat=True) + ) + + transaction.on_commit( + lambda: broadcast_to_groups.delay( + group_ids, + {"type": "user_updated", "user": PublicUserSerializer(user).data}, + getattr(performed_by, "web_socket_id", None), + ) + ) + + +@receiver(signals.user_deleted) +def user_deleted(sender, performed_by, user, **kwargs): + group_ids = list( + GroupUser.objects.filter(user=user).values_list("group_id", flat=True) + ) + + transaction.on_commit( + lambda: broadcast_to_groups.delay( + group_ids, {"type": "user_deleted", "user": PublicUserSerializer(user).data} + ) + ) + + +@receiver(signals.user_restored) +def user_restored(sender, performed_by, user, **kwargs): + group_ids = list( + GroupUser.objects.filter(user=user).values_list("group_id", flat=True) + ) + + transaction.on_commit( + lambda: broadcast_to_groups.delay( + group_ids, + {"type": "user_restored", "user": PublicUserSerializer(user).data}, + ) + ) + + +@receiver(signals.user_permanently_deleted) +def user_permanently_deleted(sender, user_id, group_ids, **kwargs): + transaction.on_commit( + lambda: broadcast_to_groups.delay( + group_ids, {"type": "user_permanently_deleted", "user_id": user_id} + ) + ) @receiver(signals.group_created) @@ -45,36 +103,32 @@ def group_deleted(sender, group_id, group, group_users, user=None, **kwargs): ) -@receiver(signals.group_user_updated) -def group_user_updated(sender, group_user, user, **kwargs): +@receiver(signals.group_user_added) +def group_user_added(sender, group_user, user, **kwargs): transaction.on_commit( - lambda: broadcast_to_users.delay( - [group_user.user_id], + lambda: broadcast_to_group.delay( + group_user.group_id, { - "type": "group_updated", + "type": "group_user_added", + "id": group_user.id, "group_id": group_user.group_id, - "group": GroupUserGroupSerializer(group_user).data, + "group_user": GroupUserSerializer(group_user).data, }, getattr(user, "web_socket_id", None), ) ) -@receiver(signals.group_restored) -def group_restored(sender, group_user, user, **kwargs): +@receiver(signals.group_user_updated) +def group_user_updated(sender, group_user, user, **kwargs): transaction.on_commit( - lambda: broadcast_to_users.delay( - [group_user.user_id], + lambda: broadcast_to_group.delay( + group_user.group_id, { - "type": "group_restored", + "type": "group_user_updated", + "id": group_user.id, "group_id": group_user.group_id, - "group": GroupUserGroupSerializer(group_user).data, - "applications": [ - get_application_serializer(application).data - for application in group_user.group.application_set.select_related( - "content_type", "group" - ).all() - ], + "group_user": GroupUserSerializer(group_user).data, }, getattr(user, "web_socket_id", None), ) @@ -82,11 +136,47 @@ def group_restored(sender, group_user, user, **kwargs): @receiver(signals.group_user_deleted) -def group_user_deleted(sender, group_user, user, **kwargs): +def group_user_deleted(sender, group_user_id, group_user, user, **kwargs): + def broadcast_to_group_and_removed_user(): + payload = { + "type": "group_user_deleted", + "id": group_user_id, + "group_id": group_user.group_id, + "group_user": GroupUserSerializer(group_user).data, + } + broadcast_to_group.delay( + group_user.group_id, + payload, + getattr(user, "web_socket_id", None), + ) + broadcast_to_users.delay( + [group_user.user_id], + payload, + getattr(user, "web_socket_id", None), + ) + + transaction.on_commit(broadcast_to_group_and_removed_user) + + +@receiver(signals.group_restored) +def group_restored(sender, group_user, user, **kwargs): + groupuser_groups = ( + CoreHandler().get_groupuser_group_queryset().get(id=group_user.id) + ) transaction.on_commit( lambda: broadcast_to_users.delay( [group_user.user_id], - {"type": "group_deleted", "group_id": group_user.group_id}, + { + "type": "group_restored", + "group_id": group_user.group_id, + "group": GroupUserGroupSerializer(groupuser_groups).data, + "applications": [ + get_application_serializer(application).data + for application in group_user.group.application_set.select_related( + "content_type", "group" + ).all() + ], + }, getattr(user, "web_socket_id", None), ) ) diff --git a/backend/src/baserow/ws/tasks.py b/backend/src/baserow/ws/tasks.py index 47d0479be..95eeebd74 100644 --- a/backend/src/baserow/ws/tasks.py +++ b/backend/src/baserow/ws/tasks.py @@ -1,3 +1,5 @@ +from typing import Iterable + from baserow.config.celery import app @@ -92,3 +94,33 @@ def broadcast_to_group(self, group_id, payload, ignore_web_socket_id=None): return broadcast_to_users(user_ids, payload, ignore_web_socket_id) + + +@app.task(bind=True) +def broadcast_to_groups( + self, group_ids: Iterable[int], payload: dict, ignore_web_socket_id: str = None +): + """ + Broadcasts a JSON payload to all users that are in the provided groups. + + :param group_ids: Ids of groups to broadcast to. + :param payload: A dictionary object containing the payload that must be + broadcasted. + :param ignore_web_socket_id: The web socket id to which the message must not be + sent. This is normally the web socket id that has originally made the change + request. + """ + + from baserow.core.models import GroupUser + + user_ids = list( + GroupUser.objects.filter(group_id__in=group_ids) + .distinct("user_id") + .order_by("user_id") + .values_list("user_id", flat=True) + ) + + if len(user_ids) == 0: + return + + broadcast_to_users(user_ids, payload, ignore_web_socket_id) diff --git a/backend/tests/baserow/api/groups/test_group_views.py b/backend/tests/baserow/api/groups/test_group_views.py index c599dbf1e..0d87d66f7 100644 --- a/backend/tests/baserow/api/groups/test_group_views.py +++ b/backend/tests/baserow/api/groups/test_group_views.py @@ -5,6 +5,7 @@ from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_404_NO from baserow.core.handler import CoreHandler from baserow.core.models import Group, GroupUser +from baserow.test_utils.helpers import is_dict_subset @pytest.mark.django_db @@ -12,8 +13,12 @@ def test_list_groups(api_client, data_fixture): user, token = data_fixture.create_user_and_token( email="test@test.nl", password="password", first_name="Test1" ) - user_group_2 = data_fixture.create_user_group(user=user, order=2) - user_group_1 = data_fixture.create_user_group(user=user, order=1) + user_group_2 = data_fixture.create_user_group( + user=user, order=2, permissions="ADMIN" + ) + user_group_1 = data_fixture.create_user_group( + user=user, order=1, permissions="MEMBER" + ) data_fixture.create_group() response = api_client.get( @@ -24,8 +29,107 @@ def test_list_groups(api_client, data_fixture): assert len(response_json) == 2 assert response_json[0]["id"] == user_group_1.group.id assert response_json[0]["order"] == 1 + assert response_json[0]["name"] == user_group_1.group.name + assert response_json[0]["permissions"] == "MEMBER" assert response_json[1]["id"] == user_group_2.group.id assert response_json[1]["order"] == 2 + assert response_json[1]["name"] == user_group_2.group.name + assert response_json[1]["permissions"] == "ADMIN" + + +@pytest.mark.django_db +def test_list_groups_with_users(api_client, data_fixture): + user, token = data_fixture.create_user_and_token( + email="test@test.nl", password="password", first_name="Test1" + ) + user_group_1 = data_fixture.create_user_group( + user=user, order=1, permissions="MEMBER" + ) + user_group_1_2 = data_fixture.create_user_group( + order=1, permissions="ADMIN", group=user_group_1.group + ) + user_group_1_3 = data_fixture.create_user_group( + order=1, permissions="MEMBER", group=user_group_1.group + ) + + user_group_2 = data_fixture.create_user_group( + user=user, order=2, permissions="ADMIN" + ) + user_group_2_2 = data_fixture.create_user_group( + order=2, permissions="MEMBER", group=user_group_2.group + ) + + response = api_client.get( + reverse("api:groups:list"), **{"HTTP_AUTHORIZATION": f"JWT {token}"} + ) + assert response.status_code == HTTP_200_OK + response_json = response.json() + + expected_result = [ + { + "id": user_group_1.group.id, + "name": user_group_1.group.name, + "order": 1, + "permissions": "MEMBER", + "users": [ + { + "email": user_group_1.user.email, + "group": user_group_1.group.id, + "id": user_group_1.id, + "name": user_group_1.user.first_name, + "permissions": "MEMBER", + "to_be_deleted": False, + "user_id": user_group_1.user.id, + }, + { + "email": user_group_1_2.user.email, + "group": user_group_1_2.group.id, + "id": user_group_1_2.id, + "name": user_group_1_2.user.first_name, + "permissions": "ADMIN", + "to_be_deleted": False, + "user_id": user_group_1_2.user.id, + }, + { + "email": user_group_1_3.user.email, + "group": user_group_1_3.group.id, + "id": user_group_1_3.id, + "name": user_group_1_3.user.first_name, + "permissions": "MEMBER", + "to_be_deleted": False, + "user_id": user_group_1_3.user.id, + }, + ], + }, + { + "id": user_group_2.group.id, + "name": user_group_2.group.name, + "order": 2, + "permissions": "ADMIN", + "users": [ + { + "email": user_group_2.user.email, + "group": user_group_2.group.id, + "id": user_group_2.id, + "name": user_group_2.user.first_name, + "permissions": "ADMIN", + "to_be_deleted": False, + "user_id": user_group_2.user.id, + }, + { + "email": user_group_2_2.user.email, + "group": user_group_2_2.group.id, + "id": user_group_2_2.id, + "name": user_group_2_2.user.first_name, + "permissions": "MEMBER", + "to_be_deleted": False, + "user_id": user_group_2_2.user.id, + }, + ], + }, + ] + + assert is_dict_subset(expected_result, response_json) @pytest.mark.django_db diff --git a/backend/tests/baserow/ws/test_ws_signals.py b/backend/tests/baserow/ws/test_ws_signals.py index 4a5ae6120..78c9c2f0d 100644 --- a/backend/tests/baserow/ws/test_ws_signals.py +++ b/backend/tests/baserow/ws/test_ws_signals.py @@ -1,6 +1,8 @@ +from datetime import timedelta from unittest.mock import patch from django.db import transaction +from django.utils import timezone import pytest @@ -10,6 +12,78 @@ from baserow.core.models import ( GROUP_USER_PERMISSION_MEMBER, ) from baserow.core.trash.handler import TrashHandler +from baserow.core.user.handler import UserHandler + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.ws.signals.broadcast_to_groups") +def test_user_updated_name(mock_broadcast_to_groups, data_fixture): + user = data_fixture.create_user(first_name="Albert") + group_user = CoreHandler().create_group(user=user, name="Test") + group_user_2 = CoreHandler().create_group(user=user, name="Test 2") + + UserHandler().update_user(user, first_name="Jack") + + mock_broadcast_to_groups.delay.assert_called_once() + args = mock_broadcast_to_groups.delay.call_args + assert args[0][0] == [group_user.group.id, group_user_2.group.id] + assert args[0][1]["type"] == "user_updated" + assert args[0][1]["user"]["id"] == user.id + assert args[0][1]["user"]["first_name"] == "Jack" + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.ws.signals.broadcast_to_groups") +def test_schedule_user_deletion(mock_broadcast_to_groups, data_fixture): + user = data_fixture.create_user(first_name="Albert", password="albert") + group_user = CoreHandler().create_group(user=user, name="Test") + group_user_2 = CoreHandler().create_group(user=user, name="Test 2") + + UserHandler().schedule_user_deletion(user, password="albert") + + mock_broadcast_to_groups.delay.assert_called_once() + args = mock_broadcast_to_groups.delay.call_args + assert args[0][0] == [group_user.group.id, group_user_2.group.id] + assert args[0][1]["type"] == "user_deleted" + assert args[0][1]["user"]["id"] == user.id + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.ws.signals.broadcast_to_groups") +def test_cancel_user_deletion(mock_broadcast_to_groups, data_fixture): + user = data_fixture.create_user(first_name="Albert", password="albert") + user.profile.to_be_deleted = True + user.save() + group_user = CoreHandler().create_group(user=user, name="Test") + group_user_2 = CoreHandler().create_group(user=user, name="Test 2") + + UserHandler().cancel_user_deletion(user) + + mock_broadcast_to_groups.delay.assert_called_once() + args = mock_broadcast_to_groups.delay.call_args + assert args[0][0] == [group_user.group.id, group_user_2.group.id] + assert args[0][1]["type"] == "user_restored" + assert args[0][1]["user"]["id"] == user.id + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.ws.signals.broadcast_to_groups") +def test_user_permanently_deleted(mock_broadcast_to_groups, data_fixture): + user = data_fixture.create_user(first_name="Albert", password="albert") + user.profile.to_be_deleted = True + user.profile.save() + user.last_login = timezone.now() - timedelta(weeks=100) + user.save() + group_user = CoreHandler().create_group(user=user, name="Test") + group_user_2 = CoreHandler().create_group(user=user, name="Test 2") + + UserHandler().delete_expired_users(grace_delay=timedelta(days=1)) + + mock_broadcast_to_groups.delay.assert_called_once() + args = mock_broadcast_to_groups.delay.call_args + assert args[0][0] == [group_user.group.id, group_user_2.group.id] + assert args[0][1]["type"] == "user_permanently_deleted" + assert args[0][1]["user_id"] == user.id @pytest.mark.django_db(transaction=True) @@ -106,8 +180,31 @@ def test_group_deleted(mock_broadcast_to_users, data_fixture): @pytest.mark.django_db(transaction=True) -@patch("baserow.ws.signals.broadcast_to_users") -def test_group_user_updated(mock_broadcast_to_users, data_fixture): +@patch("baserow.ws.signals.broadcast_to_group") +def test_group_user_added(mock_broadcast_to_group, data_fixture): + user_1 = data_fixture.create_user() + user_2 = data_fixture.create_user() + group = data_fixture.create_group() + group_user_1 = data_fixture.create_user_group(user=user_1, group=group) + group_invitation = data_fixture.create_group_invitation( + email=user_2.email, permissions="MEMBER", group=group + ) + + group_user_2 = CoreHandler().accept_group_invitation(user_2, group_invitation) + + mock_broadcast_to_group.delay.assert_called_once() + args = mock_broadcast_to_group.delay.call_args + assert args[0][0] == group.id + assert args[0][1]["type"] == "group_user_added" + assert args[0][1]["id"] == group_user_2.id + assert args[0][1]["group_id"] == group.id + assert args[0][1]["group_user"]["user_id"] == group_user_2.user_id + assert args[0][1]["group_user"]["permissions"] == "MEMBER" + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.ws.signals.broadcast_to_group") +def test_group_user_updated(mock_broadcast_to_group, data_fixture): user_1 = data_fixture.create_user() user_2 = data_fixture.create_user() group = data_fixture.create_group() @@ -117,31 +214,76 @@ def test_group_user_updated(mock_broadcast_to_users, data_fixture): user=user_2, group_user=group_user_1, permissions="MEMBER" ) - mock_broadcast_to_users.delay.assert_called_once() - args = mock_broadcast_to_users.delay.call_args - assert args[0][0] == [user_1.id] - assert args[0][1]["type"] == "group_updated" - assert args[0][1]["group"]["id"] == group.id - assert args[0][1]["group"]["name"] == group.name - assert args[0][1]["group"]["permissions"] == "MEMBER" + mock_broadcast_to_group.delay.assert_called_once() + args = mock_broadcast_to_group.delay.call_args + assert args[0][0] == group.id + assert args[0][1]["type"] == "group_user_updated" + assert args[0][1]["id"] == group_user_1.id assert args[0][1]["group_id"] == group.id + assert args[0][1]["group_user"]["user_id"] == group_user_1.user_id + assert args[0][1]["group_user"]["permissions"] == "MEMBER" @pytest.mark.django_db(transaction=True) +@patch("baserow.ws.signals.broadcast_to_group") @patch("baserow.ws.signals.broadcast_to_users") -def test_group_user_deleted(mock_broadcast_to_users, data_fixture): +def test_group_user_deleted( + mock_broadcast_to_users, mock_broadcast_to_group, data_fixture +): user_1 = data_fixture.create_user() user_2 = data_fixture.create_user() group = data_fixture.create_group() group_user_1 = data_fixture.create_user_group(user=user_1, group=group) + group_user_id = group_user_1.id data_fixture.create_user_group(user=user_2, group=group) CoreHandler().delete_group_user(user=user_2, group_user=group_user_1) mock_broadcast_to_users.delay.assert_called_once() args = mock_broadcast_to_users.delay.call_args assert args[0][0] == [user_1.id] - assert args[0][1]["type"] == "group_deleted" + assert args[0][1]["type"] == "group_user_deleted" + assert args[0][1]["id"] == group_user_id assert args[0][1]["group_id"] == group.id + assert args[0][1]["group_user"]["user_id"] == group_user_1.user_id + + mock_broadcast_to_group.delay.assert_called_once() + args = mock_broadcast_to_group.delay.call_args + assert args[0][0] == group.id + assert args[0][1]["type"] == "group_user_deleted" + assert args[0][1]["id"] == group_user_id + assert args[0][1]["group_id"] == group.id + assert args[0][1]["group_user"]["user_id"] == group_user_1.user_id + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.ws.signals.broadcast_to_group") +@patch("baserow.ws.signals.broadcast_to_users") +def test_user_leaves_group( + mock_broadcast_to_users, mock_broadcast_to_group, data_fixture +): + user_1 = data_fixture.create_user() + user_2 = data_fixture.create_user() + group = data_fixture.create_group() + group_user_1 = data_fixture.create_user_group(user=user_1, group=group) + group_user_id = group_user_1.id + data_fixture.create_user_group(user=user_2, group=group) + CoreHandler().leave_group(user_1, group) + + mock_broadcast_to_users.delay.assert_called_once() + args = mock_broadcast_to_users.delay.call_args + assert args[0][0] == [user_1.id] + assert args[0][1]["type"] == "group_user_deleted" + assert args[0][1]["id"] == group_user_id + assert args[0][1]["group_id"] == group.id + assert args[0][1]["group_user"]["user_id"] == group_user_1.user_id + + mock_broadcast_to_group.delay.assert_called_once() + args = mock_broadcast_to_group.delay.call_args + assert args[0][0] == group.id + assert args[0][1]["type"] == "group_user_deleted" + assert args[0][1]["id"] == group_user_id + assert args[0][1]["group_id"] == group.id + assert args[0][1]["group_user"]["user_id"] == group_user_1.user_id @pytest.mark.django_db(transaction=True) diff --git a/backend/tests/baserow/ws/test_ws_tasks.py b/backend/tests/baserow/ws/test_ws_tasks.py index 18faa9711..8d324332b 100644 --- a/backend/tests/baserow/ws/test_ws_tasks.py +++ b/backend/tests/baserow/ws/test_ws_tasks.py @@ -7,6 +7,7 @@ from baserow.config.asgi import application from baserow.ws.tasks import ( broadcast_to_channel_group, broadcast_to_group, + broadcast_to_groups, broadcast_to_users, ) @@ -234,3 +235,89 @@ async def test_broadcast_to_group(data_fixture): await communicator_1.disconnect() await communicator_2.disconnect() await communicator_3.disconnect() + + +@pytest.mark.run(order=6) +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +async def test_broadcast_to_groups(data_fixture): + user_1, token_1 = data_fixture.create_user_and_token() + user_2, token_2 = data_fixture.create_user_and_token() + user_3, token_3 = data_fixture.create_user_and_token() + user_4, token_4 = data_fixture.create_user_and_token() + group_1 = data_fixture.create_group(users=[user_1, user_2, user_4]) + group_2 = data_fixture.create_group(users=[user_2, user_3]) + + communicator_1 = WebsocketCommunicator( + application, + f"ws/core/?jwt_token={token_1}", + headers=[(b"origin", b"http://localhost")], + ) + await communicator_1.connect() + response_1 = await communicator_1.receive_json_from() + web_socket_id_1 = response_1["web_socket_id"] + + communicator_2 = WebsocketCommunicator( + application, + f"ws/core/?jwt_token={token_2}", + headers=[(b"origin", b"http://localhost")], + ) + await communicator_2.connect() + response_2 = await communicator_2.receive_json_from() + web_socket_id_2 = response_2["web_socket_id"] + + communicator_3 = WebsocketCommunicator( + application, + f"ws/core/?jwt_token={token_3}", + headers=[(b"origin", b"http://localhost")], + ) + await communicator_3.connect() + await communicator_3.receive_json_from() + + await database_sync_to_async(broadcast_to_groups)([group_1.id], {"message": "test"}) + response_1 = await communicator_1.receive_json_from(0.1) + response_2 = await communicator_2.receive_json_from(0.1) + await communicator_3.receive_nothing(0.1) + + assert response_1["message"] == "test" + assert response_2["message"] == "test" + + await database_sync_to_async(broadcast_to_groups)( + [group_1.id], {"message": "test2"}, ignore_web_socket_id=web_socket_id_1 + ) + + await communicator_1.receive_nothing(0.1) + response_2 = await communicator_2.receive_json_from(0.1) + await communicator_3.receive_nothing(0.1) + + assert response_2["message"] == "test2" + + communicator_4 = WebsocketCommunicator( + application, + f"ws/core/?jwt_token={token_4}", + headers=[(b"origin", b"http://localhost")], + ) + await communicator_4.connect() + response_4 = await communicator_4.receive_json_from() + web_socket_id_4 = response_4["web_socket_id"] + + await database_sync_to_async(broadcast_to_groups)( + [group_1.id, group_2.id], + {"message": "test3"}, + ignore_web_socket_id=web_socket_id_4, + ) + + await communicator_1.receive_json_from(0.1) + await communicator_2.receive_json_from(0.1) + await communicator_3.receive_json_from(0.1) + await communicator_4.receive_nothing(0.1) + + assert communicator_1.output_queue.qsize() == 0 + assert communicator_2.output_queue.qsize() == 0 + assert communicator_3.output_queue.qsize() == 0 + assert communicator_4.output_queue.qsize() == 0 + + await communicator_1.disconnect() + await communicator_2.disconnect() + await communicator_3.disconnect() + await communicator_4.disconnect() diff --git a/changelog.md b/changelog.md index 22c8ed6e6..2e2aecda6 100644 --- a/changelog.md +++ b/changelog.md @@ -19,6 +19,8 @@ For example: * Force browser language when viewing a public view. [#834](https://gitlab.com/bramw/baserow/-/issues/834) * Search automatically after 400ms when chosing a related field via the modal. [#1091](https://gitlab.com/bramw/baserow/-/issues/1091) * Add cancel button to field update context [#1020](https://gitlab.com/bramw/baserow/-/issues/1020) +* New signals `user_updated`, `user_deleted`, `user_restored`, `user_permanently_deleted` were added to track user changes. +* `list_groups` endpoint now also returns the list of all group users for each group. ### Bug Fixes * Resolve circular dependency in `FieldWithFiltersAndSortsSerializer` [#1113](https://gitlab.com/bramw/baserow/-/issues/1113) diff --git a/docs/apis/web-socket-api.md b/docs/apis/web-socket-api.md index d91bc5c43..20983bad8 100644 --- a/docs/apis/web-socket-api.md +++ b/docs/apis/web-socket-api.md @@ -123,6 +123,10 @@ are subscribed to the page. * `page_add` * `page_discard` * `before_group_deleted` +* `user_updated` +* `user_deleted` +* `user_restored` +* `user_permanently_deleted` * `group_created` * `group_updated` * `group_deleted` diff --git a/web-frontend/modules/core/components/group/GroupMembersModal.vue b/web-frontend/modules/core/components/group/GroupMembersModal.vue index 80131d2aa..3eb22a747 100644 --- a/web-frontend/modules/core/components/group/GroupMembersModal.vue +++ b/web-frontend/modules/core/components/group/GroupMembersModal.vue @@ -196,6 +196,11 @@ export default { try { await GroupService(this.$client).updateUser(user.id, { permissions }) + await this.$store.dispatch('group/forceUpdateGroupUser', { + groupId: this.group.id, + id: user.id, + values: { permissions }, + }) } catch (error) { user.permissions = oldPermissions notifyIf(error, 'group') @@ -208,7 +213,13 @@ export default { try { await GroupService(this.$client).deleteUser(user.id) const index = this.users.findIndex((u) => u.id === user.id) + const userId = this.users[index].user_id this.users.splice(index, 1) + await this.$store.dispatch('group/forceDeleteGroupUser', { + groupId: this.group.id, + id: user.id, + values: { user_id: userId }, + }) } catch (error) { user._.loading = false notifyIf(error, 'group') diff --git a/web-frontend/modules/core/plugins/realTimeHandler.js b/web-frontend/modules/core/plugins/realTimeHandler.js index a3e4ca898..2a454b589 100644 --- a/web-frontend/modules/core/plugins/realTimeHandler.js +++ b/web-frontend/modules/core/plugins/realTimeHandler.js @@ -199,6 +199,39 @@ export class RealTimeHandler { store.dispatch('auth/forceUpdateUserData', data.user_data) }) + this.registerEvent('user_updated', ({ store }, data) => { + store.dispatch('group/forceUpdateGroupUserAttributes', { + userId: data.user.id, + values: { + name: data.user.first_name, + }, + }) + }) + + this.registerEvent('user_deleted', ({ store }, data) => { + store.dispatch('group/forceUpdateGroupUserAttributes', { + userId: data.user.id, + values: { + to_be_deleted: true, + }, + }) + }) + + this.registerEvent('user_restored', ({ store }, data) => { + store.dispatch('group/forceUpdateGroupUserAttributes', { + userId: data.user.id, + values: { + to_be_deleted: false, + }, + }) + }) + + this.registerEvent('user_permanently_deleted', ({ store }, data) => { + store.dispatch('group/forceDeleteUser', { + userId: data.user_id, + }) + }) + this.registerEvent('group_created', ({ store }, data) => { store.dispatch('group/forceCreate', data.group) }) @@ -226,6 +259,29 @@ export class RealTimeHandler { store.dispatch('group/forceOrder', data.group_ids) }) + this.registerEvent('group_user_added', ({ store }, data) => { + store.dispatch('group/forceAddGroupUser', { + groupId: data.group_id, + values: data.group_user, + }) + }) + + this.registerEvent('group_user_updated', ({ store }, data) => { + store.dispatch('group/forceUpdateGroupUser', { + id: data.id, + groupId: data.group_id, + values: data.group_user, + }) + }) + + this.registerEvent('group_user_deleted', ({ store }, data) => { + store.dispatch('group/forceDeleteGroupUser', { + id: data.id, + groupId: data.group_id, + values: data.group_user, + }) + }) + this.registerEvent('application_created', ({ store }, data) => { store.dispatch('application/forceCreate', data.application) }) diff --git a/web-frontend/modules/core/store/auth.js b/web-frontend/modules/core/store/auth.js index 0a23c70b7..c57894c8c 100644 --- a/web-frontend/modules/core/store/auth.js +++ b/web-frontend/modules/core/store/auth.js @@ -160,9 +160,21 @@ export const actions = { /** * Updates the account information is the authenticated user. */ - async update({ commit }, values) { + async update({ getters, commit, dispatch }, values) { const { data } = await AuthService(this.$client).update(values) commit('UPDATE_USER_DATA', { user: data }) + dispatch( + 'group/forceUpdateGroupUserAttributes', + { + userId: getters.getUserId, + values: { + name: data.first_name, + }, + }, + { + root: true, + } + ) return data }, forceUpdateUserData({ commit }, data) { diff --git a/web-frontend/modules/core/store/group.js b/web-frontend/modules/core/store/group.js index 652244a7f..582347b37 100644 --- a/web-frontend/modules/core/store/group.js +++ b/web-frontend/modules/core/store/group.js @@ -69,6 +69,38 @@ export const mutations = { }) state.selected = {} }, + ADD_GROUP_USER(state, { groupId, values }) { + const groupIndex = state.items.findIndex((item) => item.id === groupId) + if (groupIndex !== -1) { + state.items[groupIndex].users.push(values) + } + }, + UPDATE_GROUP_USER(state, { groupId, id, values }) { + const groupIndex = state.items.findIndex((item) => item.id === groupId) + if (groupIndex === -1) { + return + } + const usersIndex = state.items[groupIndex].users.findIndex( + (item) => item.id === id + ) + if (usersIndex === -1) { + return + } + Object.assign( + state.items[groupIndex].users[usersIndex], + state.items[groupIndex].users[usersIndex], + values + ) + }, + DELETE_GROUP_USER(state, { groupId, id }) { + const groupIndex = state.items.findIndex((item) => item.id === groupId) + if (groupIndex === -1) { + return + } + state.items[groupIndex].users = state.items[groupIndex].users.filter( + (item) => item.id !== id + ) + }, } export const actions = { @@ -240,6 +272,77 @@ export const actions = { }) return dispatch('application/clearAll', group, { root: true }) }, + /** + * Forcefully adds a group user in the list of group's users. + */ + forceAddGroupUser({ commit }, { groupId, values }) { + commit('ADD_GROUP_USER', { groupId, values }) + }, + /** + * Forcefully updates a group user in the list of group's users + * and updates group's permissions attr if the group user is the + * same as the current user. + */ + forceUpdateGroupUser({ commit, rootGetters }, { groupId, id, values }) { + commit('UPDATE_GROUP_USER', { groupId, id, values }) + const userId = rootGetters['auth/getUserId'] + if (values.user_id === userId) { + commit('UPDATE_ITEM', { + id: groupId, + values: { permissions: values.permissions }, + }) + } + }, + /** + * Forcefully updates user's properties based on userId across all group users + * of all groups. Can be used e.g. to change the user's name across all group + * users that represent the same user in the system. + */ + forceUpdateGroupUserAttributes({ commit, rootGetters }, { userId, values }) { + const groups = rootGetters['group/getAll'] + for (const group of groups) { + const usersIndex = group.users.findIndex( + (item) => item.user_id === userId + ) + if (usersIndex !== -1) { + commit('UPDATE_GROUP_USER', { + groupId: group.id, + id: group.users[usersIndex].id, + values, + }) + } + } + }, + /** + * Forcefully deletes a group user in the list of group's users. If the + * group user is the current user, the whole group is removed. + */ + forceDeleteGroupUser({ commit, rootGetters }, { groupId, id, values }) { + const userId = rootGetters['auth/getUserId'] + if (values.user_id === userId) { + commit('DELETE_ITEM', { id: groupId }) + } else { + commit('DELETE_GROUP_USER', { groupId, id }) + } + }, + /** + * Forcefully deletes a user by deleting various user group instances + * in all groups where the user is present. + */ + forceDeleteUser({ commit, rootGetters }, { userId }) { + const groups = rootGetters['group/getAll'] + for (const group of groups) { + const usersIndex = group.users.findIndex( + (item) => item.user_id === userId + ) + if (usersIndex !== -1) { + commit('DELETE_GROUP_USER', { + groupId: group.id, + id: group.users[usersIndex].id, + }) + } + } + }, } export const getters = { diff --git a/web-frontend/test/unit/core/store/group.spec.js b/web-frontend/test/unit/core/store/group.spec.js new file mode 100644 index 000000000..95f21407c --- /dev/null +++ b/web-frontend/test/unit/core/store/group.spec.js @@ -0,0 +1,456 @@ +import groupStore from '@baserow/modules/core/store/group' +import { TestApp } from '@baserow/test/helpers/testApp' + +describe('Group store', () => { + let testApp = null + let store = null + + beforeEach(() => { + testApp = new TestApp() + store = testApp.store + }) + + afterEach(() => { + testApp.afterEach() + }) + + test('forceAddGroupUser adds user to a group', async () => { + const state = Object.assign(groupStore.state(), { + items: [ + { + id: 1, + name: 'Group 1', + order: 1, + permissions: 'ADMIN', + users: [ + { + id: 73, + user_id: 256, + group: 1, + name: 'John', + email: 'john@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + ], + }, + ], + }) + groupStore.state = () => state + store.registerModule('test', groupStore) + + await store.dispatch('test/forceAddGroupUser', { + groupId: 1, + values: { + id: 74, + user_id: 257, + group: 1, + name: 'Adam', + email: 'adam@example.com', + permissions: 'MEMBER', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + }) + + const group = store.getters['test/get'](1) + expect(group.users.length).toBe(2) + expect(group.users[1].user_id).toBe(257) + expect(group.users[1].permissions).toBe('MEMBER') + }) + + test('forceUpdateGroupUser updates a user from the group', async () => { + const state = Object.assign(groupStore.state(), { + items: [ + { + id: 1, + name: 'Group 1', + order: 1, + permissions: 'ADMIN', + users: [ + { + id: 73, + user_id: 256, + group: 1, + name: 'John', + email: 'john@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + ], + }, + ], + }) + groupStore.state = () => state + store.registerModule('test', groupStore) + + await store.dispatch('test/forceUpdateGroupUser', { + groupId: 1, + id: 73, + values: { + id: 73, + user_id: 256, + group: 1, + name: 'Petr', + email: 'petr@example.com', + permissions: 'MEMBER', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + }) + + const group = store.getters['test/get'](1) + expect(group.users.length).toBe(1) + expect(group.users[0].name).toBe('Petr') + expect(group.users[0].permissions).toBe('MEMBER') + }) + + test(`forceUpdateGroupUser updates a current group permissions + when the current user is updated`, async () => { + await store.dispatch('auth/forceSetUserData', { + user: { + id: 256, + }, + token: + `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImpvaG5AZXhhb` + + `XBsZS5jb20iLCJpYXQiOjE2NjAyOTEwODYsImV4cCI6MTY2MDI5NDY4NiwianRpIjo` + + `iNDZmNzUwZWUtMTJhMS00N2UzLWJiNzQtMDIwYWM4Njg3YWMzIiwidXNlcl9pZCI6M` + + `iwidXNlcl9wcm9maWxlX2lkIjpbMl0sIm9yaWdfaWF0IjoxNjYwMjkxMDg2fQ.RQ-M` + + `NQdDR9zTi8CbbQkRrwNsyDa5CldQI83Uid1l9So`, + }) + const state = Object.assign(groupStore.state(), { + items: [ + { + id: 1, + name: 'Group 1', + order: 1, + permissions: 'ADMIN', + users: [ + { + id: 73, + user_id: 256, + group: 1, + name: 'John', + email: 'john@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + ], + }, + ], + }) + groupStore.state = () => state + store.registerModule('test', groupStore) + + await store.dispatch('test/forceUpdateGroupUser', { + groupId: 1, + id: 73, + values: { + id: 73, + user_id: 256, + group: 1, + name: 'Petr', + email: 'petr@example.com', + permissions: 'MEMBER', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + }) + + const group = store.getters['test/get'](1) + expect(group.users.length).toBe(1) + expect(group.users[0].name).toBe('Petr') + expect(group.users[0].permissions).toBe('MEMBER') + + expect(group.permissions).toBe('MEMBER') + }) + + test('forceUpdateGroupUserAttributes updates a user across all groups', async () => { + const state = Object.assign(groupStore.state(), { + items: [ + { + id: 1, + name: 'Group 1', + order: 1, + permissions: 'ADMIN', + users: [ + { + id: 73, + user_id: 256, + group: 1, + name: 'John', + email: 'john@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + ], + }, + { + id: 2, + name: 'Group 2', + order: 1, + permissions: 'ADMIN', + users: [ + { + id: 2136, + user_id: 456, + group: 2, + name: 'Peter', + email: 'peter@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + { + id: 173, + user_id: 256, + group: 2, + name: 'John', + email: 'john@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + ], + }, + { + id: 3, + name: 'Group 3', + order: 1, + permissions: 'ADMIN', + users: [ + { + id: 2132, + user_id: 456, + group: 3, + name: 'Peter', + email: 'peter@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + ], + }, + ], + }) + groupStore.state = () => state + store.registerModule('test', groupStore) + + await store.dispatch('test/forceUpdateGroupUserAttributes', { + userId: 256, + values: { + name: 'John renamed', + to_be_deleted: true, + }, + }) + + const group = store.getters['test/get'](1) + expect(group.users[0].name).toBe('John renamed') + expect(group.users[0].to_be_deleted).toBe(true) + + const group2 = store.getters['test/get'](2) + expect(group2.users[0].name).toBe('Peter') + expect(group2.users[0].to_be_deleted).toBe(false) + expect(group2.users[1].name).toBe('John renamed') + expect(group2.users[1].to_be_deleted).toBe(true) + + const group3 = store.getters['test/get'](3) + expect(group3.users[0].name).toBe('Peter') + expect(group3.users[0].to_be_deleted).toBe(false) + }) + + test('forceDeleteGroupUser removes a user from the group', async () => { + const state = Object.assign(groupStore.state(), { + items: [ + { + id: 1, + name: 'Group 1', + order: 1, + permissions: 'ADMIN', + users: [ + { + id: 73, + user_id: 256, + group: 1, + name: 'John', + email: 'john@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + ], + }, + ], + }) + groupStore.state = () => state + store.registerModule('test', groupStore) + + await store.dispatch('test/forceDeleteGroupUser', { + groupId: 1, + id: 73, + values: { + id: 73, + user_id: 256, + group: 1, + name: 'John', + email: 'john@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + }) + + const group = store.getters['test/get'](1) + expect(group.users.length).toBe(0) + }) + + test(`forceDeleteGroupUser removes the whole group if the + current user is being removed`, async () => { + await store.dispatch('auth/forceSetUserData', { + user: { + id: 256, + }, + token: + `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImpvaG5AZXhhb` + + `XBsZS5jb20iLCJpYXQiOjE2NjAyOTEwODYsImV4cCI6MTY2MDI5NDY4NiwianRpIjo` + + `iNDZmNzUwZWUtMTJhMS00N2UzLWJiNzQtMDIwYWM4Njg3YWMzIiwidXNlcl9pZCI6M` + + `iwidXNlcl9wcm9maWxlX2lkIjpbMl0sIm9yaWdfaWF0IjoxNjYwMjkxMDg2fQ.RQ-M` + + `NQdDR9zTi8CbbQkRrwNsyDa5CldQI83Uid1l9So`, + }) + + const state = Object.assign(groupStore.state(), { + items: [ + { + id: 1, + name: 'Group 1', + order: 1, + permissions: 'ADMIN', + users: [ + { + id: 73, + user_id: 256, + group: 1, + name: 'John', + email: 'john@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + ], + }, + ], + }) + groupStore.state = () => state + store.registerModule('test', groupStore) + + await store.dispatch('test/forceDeleteGroupUser', { + groupId: 1, + id: 73, + values: { + id: 73, + user_id: 256, + group: 1, + name: 'John', + email: 'john@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + }) + + const groups = store.getters['test/getAll'] + expect(groups.length).toBe(0) + }) + + test('forceDeleteUser deletes all group users across all groups', async () => { + const state = Object.assign(groupStore.state(), { + items: [ + { + id: 1, + name: 'Group 1', + order: 1, + permissions: 'ADMIN', + users: [ + { + id: 73, + user_id: 256, + group: 1, + name: 'John', + email: 'john@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + ], + }, + { + id: 2, + name: 'Group 2', + order: 1, + permissions: 'ADMIN', + users: [ + { + id: 2136, + user_id: 456, + group: 2, + name: 'Peter', + email: 'peter@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + { + id: 173, + user_id: 256, + group: 2, + name: 'John', + email: 'john@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + ], + }, + { + id: 3, + name: 'Group 3', + order: 1, + permissions: 'ADMIN', + users: [ + { + id: 2132, + user_id: 456, + group: 3, + name: 'Peter', + email: 'peter@example.com', + permissions: 'ADMIN', + to_be_deleted: false, + created_on: '2022-08-10T14:20:05.629890Z', + }, + ], + }, + ], + }) + groupStore.state = () => state + store.registerModule('test', groupStore) + + await store.dispatch('test/forceDeleteUser', { + userId: 256, + }) + + const group = store.getters['test/get'](1) + expect(group.users.length).toBe(0) + + const group2 = store.getters['test/get'](2) + expect(group2.users[0].name).toBe('Peter') + expect(group2.users[0].to_be_deleted).toBe(false) + expect(group2.users.length).toBe(1) + + const group3 = store.getters['test/get'](3) + expect(group3.users.length).toBe(1) + }) +})