1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-11 07:51:20 +00:00

Merge branch '138-inviting-users-to-a-group' into 'develop'

Resolve "Inviting users to a group"

Closes , , and 

See merge request 
This commit is contained in:
Bram Wiepjes 2021-02-04 16:16:33 +00:00
commit b90b81c327
88 changed files with 4260 additions and 316 deletions
backend
changelog.md
web-frontend/modules

View file

@ -119,7 +119,9 @@ class ApplicationsView(APIView):
returned.
"""
group = CoreHandler().get_group(request.user, group_id)
group = CoreHandler().get_group(group_id)
group.has_user(request.user, raise_error=True)
applications = Application.objects.select_related(
'content_type', 'group'
).filter(group=group)
@ -171,9 +173,10 @@ class ApplicationsView(APIView):
def post(self, request, data, group_id):
"""Creates a new application for a user."""
group = CoreHandler().get_group(request.user, group_id)
group = CoreHandler().get_group(group_id)
application = CoreHandler().create_application(
request.user, group, data['type'], name=data['name'])
request.user, group, data['type'], name=data['name']
)
return Response(get_application_serializer(application).data)

View file

@ -1,4 +1,4 @@
from rest_framework.status import HTTP_404_NOT_FOUND
from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
ERROR_GROUP_DOES_NOT_EXIST = (
@ -6,6 +6,16 @@ ERROR_GROUP_DOES_NOT_EXIST = (
HTTP_404_NOT_FOUND,
'The requested group does not exist.'
)
ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR = (
'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR',
HTTP_400_BAD_REQUEST,
'You need {e.permissions} permissions.'
)
ERROR_USER_NOT_IN_GROUP = 'ERROR_USER_NOT_IN_GROUP'
BAD_TOKEN_SIGNATURE = 'BAD_TOKEN_SIGNATURE'
EXPIRED_TOKEN_SIGNATURE = 'EXPIRED_TOKEN_SIGNATURE'
ERROR_HOSTNAME_IS_NOT_ALLOWED = (
'ERROR_HOSTNAME_IS_NOT_ALLOWED',
HTTP_400_BAD_REQUEST,
'Only the hostname of the web frontend is allowed.'
)

View file

@ -0,0 +1,13 @@
from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
ERROR_GROUP_INVITATION_DOES_NOT_EXIST = (
'ERROR_GROUP_INVITATION_DOES_NOT_EXIST',
HTTP_404_NOT_FOUND,
'The requested group invitation does not exist.'
)
ERROR_GROUP_INVITATION_EMAIL_MISMATCH = (
'ERROR_GROUP_INVITATION_EMAIL_MISMATCH',
HTTP_400_BAD_REQUEST,
'Your email address does not match with the invitation.'
)

View file

@ -0,0 +1,66 @@
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.openapi import OpenApiTypes
from rest_framework import serializers
from baserow.core.models import GroupInvitation
class GroupInvitationSerializer(serializers.ModelSerializer):
class Meta:
model = GroupInvitation
fields = ('id', 'group', 'email', 'permissions', 'message', 'created_on')
extra_kwargs = {
'id': {'read_only': True}
}
class CreateGroupInvitationSerializer(serializers.ModelSerializer):
base_url = serializers.URLField(
help_text='The base URL where the user can publicly accept his invitation.'
'The accept token is going to be appended to the base_url (base_url '
'\'/token\').'
)
class Meta:
model = GroupInvitation
fields = ('email', 'permissions', 'message', 'base_url')
class UpdateGroupInvitationSerializer(serializers.ModelSerializer):
class Meta:
model = GroupInvitation
fields = ('permissions',)
class UserGroupInvitationSerializer(serializers.ModelSerializer):
"""
This serializer is used for displaying the invitation to the user that doesn't
have access to the group yet, so not for invitation management purposes.
"""
invited_by = serializers.SerializerMethodField()
group = serializers.SerializerMethodField()
email_exists = serializers.SerializerMethodField()
class Meta:
model = GroupInvitation
fields = ('id', 'invited_by', 'group', 'email', 'message', 'created_on',
'email_exists')
extra_kwargs = {
'id': {'read_only': True},
'message': {'read_only': True},
'created_on': {'read_only': True}
}
@extend_schema_field(OpenApiTypes.STR)
def get_invited_by(self, object):
return object.invited_by.first_name
@extend_schema_field(OpenApiTypes.STR)
def get_group(self, object):
return object.group.name
@extend_schema_field(OpenApiTypes.BOOL)
def get_email_exists(self, object):
return object.email_exists if hasattr(object, 'email_exists') else None

View file

@ -0,0 +1,34 @@
from django.conf.urls import url
from .views import (
GroupInvitationsView, GroupInvitationView, AcceptGroupInvitationView,
RejectGroupInvitationView, GroupInvitationByTokenView
)
app_name = 'baserow.api.groups.invitations'
urlpatterns = [
url(r'group/(?P<group_id>[0-9]+)/$', GroupInvitationsView.as_view(), name='list'),
url(
r'token/(?P<token>.*)/$',
GroupInvitationByTokenView.as_view(),
name='token'
),
url(
r'(?P<group_invitation_id>[0-9]+)/$',
GroupInvitationView.as_view(),
name='item'
),
url(
r'(?P<group_invitation_id>[0-9]+)/accept/$',
AcceptGroupInvitationView.as_view(),
name='accept'
),
url(
r'(?P<group_invitation_id>[0-9]+)/reject/$',
RejectGroupInvitationView.as_view(),
name='reject'
),
]

View file

@ -0,0 +1,407 @@
from django.db import transaction
from django.db.models import Exists, OuterRef
from django.contrib.auth import get_user_model
from itsdangerous.exc import BadSignature
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny
from drf_spectacular.utils import extend_schema
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
from baserow.api.decorators import validate_body, map_exceptions
from baserow.api.errors import (
ERROR_USER_NOT_IN_GROUP, ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR,
ERROR_GROUP_DOES_NOT_EXIST, ERROR_HOSTNAME_IS_NOT_ALLOWED,
BAD_TOKEN_SIGNATURE
)
from baserow.api.schemas import get_error_schema
from baserow.api.groups.serializers import GroupUserGroupSerializer
from baserow.api.groups.users.errors import ERROR_GROUP_USER_ALREADY_EXISTS
from baserow.api.groups.invitations.errors import (
ERROR_GROUP_INVITATION_DOES_NOT_EXIST, ERROR_GROUP_INVITATION_EMAIL_MISMATCH
)
from baserow.core.models import GroupInvitation
from baserow.core.handler import CoreHandler
from baserow.core.exceptions import (
UserNotInGroupError, UserInvalidGroupPermissionsError, GroupDoesNotExist,
GroupInvitationDoesNotExist, BaseURLHostnameNotAllowed,
GroupInvitationEmailMismatch, GroupUserAlreadyExists
)
from .serializers import (
GroupInvitationSerializer, CreateGroupInvitationSerializer,
UpdateGroupInvitationSerializer, UserGroupInvitationSerializer
)
User = get_user_model()
class GroupInvitationsView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
parameters=[
OpenApiParameter(
name='group_id',
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description='Returns only invitations that are in the group related '
'to the provided value.'
)
],
tags=['Group invitations'],
operation_id='list_group_invitations',
description=(
'Lists all the group invitations of the group related to the provided '
'`group_id` parameter if the authorized user has admin rights to that '
'group.'
),
responses={
200: GroupInvitationSerializer(many=True),
400: get_error_schema([
'ERROR_USER_NOT_IN_GROUP',
'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
]),
404: get_error_schema(['ERROR_GROUP_DOES_NOT_EXIST'])
}
)
@map_exceptions({
GroupDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST,
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
UserInvalidGroupPermissionsError: ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR
})
def get(self, request, group_id):
"""Lists all the invitations of the provided group id."""
group = CoreHandler().get_group(group_id)
group.has_user(request.user, 'ADMIN', raise_error=True)
group_invitations = GroupInvitation.objects.filter(group=group)
serializer = GroupInvitationSerializer(group_invitations, many=True)
return Response(serializer.data)
@extend_schema(
parameters=[
OpenApiParameter(
name='group_id',
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description='Creates a group invitation to the group related to the '
'provided value.'
)
],
tags=['Group invitations'],
operation_id='create_group_invitation',
description=(
'Creates a new group invitations for an email address if the authorized '
'user has admin rights to the related group. An email containing a sign '
'up link will be send to the user.'
),
request=CreateGroupInvitationSerializer,
responses={
200: GroupInvitationSerializer,
400: get_error_schema([
'ERROR_USER_NOT_IN_GROUP',
'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR',
'ERROR_REQUEST_BODY_VALIDATION'
]),
404: get_error_schema(['ERROR_GROUP_DOES_NOT_EXIST'])
},
)
@transaction.atomic
@validate_body(CreateGroupInvitationSerializer)
@map_exceptions({
GroupDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST,
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
UserInvalidGroupPermissionsError: ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR,
GroupUserAlreadyExists: ERROR_GROUP_USER_ALREADY_EXISTS,
BaseURLHostnameNotAllowed: ERROR_HOSTNAME_IS_NOT_ALLOWED
})
def post(self, request, data, group_id):
"""Creates a new group invitation and sends it the provided email."""
group = CoreHandler().get_group(group_id)
group_invitation = CoreHandler().create_group_invitation(
request.user,
group,
**data
)
return Response(GroupInvitationSerializer(group_invitation).data)
class GroupInvitationView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
parameters=[
OpenApiParameter(
name='group_invitation_id',
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description='Returns the group invitation related to the provided '
'value.'
)
],
tags=['Group invitations'],
operation_id='get_group_invitation',
description=(
'Returns the requested group invitation if the authorized user has admin '
'right to the related group'
),
responses={
200: GroupInvitationSerializer,
400: get_error_schema([
'ERROR_USER_NOT_IN_GROUP',
'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
]),
404: get_error_schema(['ERROR_GROUP_INVITATION_DOES_NOT_EXIST'])
},
)
@map_exceptions({
GroupInvitationDoesNotExist: ERROR_GROUP_INVITATION_DOES_NOT_EXIST,
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
UserInvalidGroupPermissionsError: ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR,
})
def get(self, request, group_invitation_id):
"""Selects a single group invitation and responds with a serialized version."""
group_invitation = CoreHandler().get_group_invitation(
request.user,
group_invitation_id
)
return Response(GroupInvitationSerializer(group_invitation).data)
@extend_schema(
parameters=[
OpenApiParameter(
name='group_invitation_id',
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description='Updates the group invitation related to the provided '
'value.'
)
],
tags=['Group invitations'],
operation_id='update_group_invitation',
description=(
'Updates the existing group invitation related to the provided '
'`group_invitation_id` param if the authorized user has admin rights to '
'the related group.'
),
request=UpdateGroupInvitationSerializer,
responses={
200: GroupInvitationSerializer,
400: get_error_schema([
'ERROR_USER_NOT_IN_GROUP',
'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR',
'ERROR_REQUEST_BODY_VALIDATION'
]),
404: get_error_schema(['ERROR_GROUP_INVITATION_DOES_NOT_EXIST'])
},
)
@transaction.atomic
@validate_body(UpdateGroupInvitationSerializer)
@map_exceptions({
GroupInvitationDoesNotExist: ERROR_GROUP_INVITATION_DOES_NOT_EXIST,
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
UserInvalidGroupPermissionsError: ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR
})
def patch(self, request, data, group_invitation_id):
"""Updates the group invitation if the user belongs to the group."""
group_invitation = CoreHandler().get_group_invitation(
request.user,
group_invitation_id,
base_queryset=GroupInvitation.objects.select_for_update()
)
group_invitation = CoreHandler().update_group_invitation(
request.user,
group_invitation,
**data
)
return Response(GroupInvitationSerializer(group_invitation).data)
@extend_schema(
parameters=[
OpenApiParameter(
name='group_invitation_id',
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description='Deletes the group invitation related to the provided '
'value.'
)
],
tags=['Group invitations'],
operation_id='delete_group_invitation',
description=(
'Deletes a group invitation if the authorized user has admin rights to '
'the related group.'
),
responses={
204: None,
400: get_error_schema([
'ERROR_USER_NOT_IN_GROUP',
'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
]),
404: get_error_schema(['ERROR_GROUP_INVITATION_DOES_NOT_EXIST'])
},
)
@transaction.atomic
@map_exceptions({
GroupInvitationDoesNotExist: ERROR_GROUP_INVITATION_DOES_NOT_EXIST,
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
UserInvalidGroupPermissionsError: ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR,
})
def delete(self, request, group_invitation_id):
"""Deletes an existing group_invitation if the user belongs to the group."""
group_invitation = CoreHandler().get_group_invitation(
request.user,
group_invitation_id,
base_queryset=GroupInvitation.objects.select_for_update()
)
CoreHandler().delete_group_invitation(request.user, group_invitation)
return Response(status=204)
class AcceptGroupInvitationView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
parameters=[
OpenApiParameter(
name='group_invitation_id',
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description='Accepts the group invitation related to the provided '
'value.'
)
],
tags=['Group invitations'],
operation_id='accept_group_invitation',
description=(
'Accepts a group invitation with the given id if the email address of the '
'user matches that of the invitation.'
),
responses={
200: GroupUserGroupSerializer,
400: get_error_schema(['ERROR_GROUP_INVITATION_EMAIL_MISMATCH']),
404: get_error_schema(['ERROR_GROUP_INVITATION_DOES_NOT_EXIST'])
},
)
@transaction.atomic
@map_exceptions({
GroupInvitationEmailMismatch: ERROR_GROUP_INVITATION_EMAIL_MISMATCH,
GroupInvitationDoesNotExist: ERROR_GROUP_INVITATION_DOES_NOT_EXIST,
})
def post(self, request, group_invitation_id):
"""Accepts a group invitation."""
try:
group_invitation = GroupInvitation.objects.select_related('group').get(
id=group_invitation_id
)
except GroupInvitation.DoesNotExist:
raise GroupInvitationDoesNotExist(
f'The group invitation with id {group_invitation_id} does not exist.'
)
group_user = CoreHandler().accept_group_invitation(
request.user,
group_invitation
)
return Response(GroupUserGroupSerializer(group_user).data)
class RejectGroupInvitationView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
parameters=[
OpenApiParameter(
name='group_invitation_id',
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description='Rejects the group invitation related to the provided '
'value.'
)
],
tags=['Group invitations'],
operation_id='reject_group_invitation',
description=(
'Rejects a group invitation with the given id if the email address of the '
'user matches that of the invitation.'
),
responses={
204: None,
400: get_error_schema(['ERROR_GROUP_INVITATION_EMAIL_MISMATCH']),
404: get_error_schema(['ERROR_GROUP_INVITATION_DOES_NOT_EXIST'])
},
)
@transaction.atomic
@map_exceptions({
GroupInvitationEmailMismatch: ERROR_GROUP_INVITATION_EMAIL_MISMATCH,
GroupInvitationDoesNotExist: ERROR_GROUP_INVITATION_DOES_NOT_EXIST,
})
def post(self, request, group_invitation_id):
"""Rejects a group invitation."""
try:
group_invitation = GroupInvitation.objects.select_related('group').get(
id=group_invitation_id
)
except GroupInvitation.DoesNotExist:
raise GroupInvitationDoesNotExist(
f'The group invitation with id {group_invitation_id} does not exist.'
)
CoreHandler().reject_group_invitation(request.user, group_invitation)
return Response(status=204)
class GroupInvitationByTokenView(APIView):
permission_classes = (AllowAny,)
@extend_schema(
parameters=[
OpenApiParameter(
name='token',
location=OpenApiParameter.PATH,
type=OpenApiTypes.STR,
description='Returns the group invitation related to the provided '
'token.'
)
],
tags=['Group invitations'],
operation_id='get_group_invitation_by_token',
description=(
'Responds with the serialized group invitation if an invitation with the '
'provided token is found.'
),
responses={
200: UserGroupInvitationSerializer,
400: get_error_schema(['BAD_TOKEN_SIGNATURE']),
404: get_error_schema(['ERROR_GROUP_INVITATION_DOES_NOT_EXIST'])
},
)
@map_exceptions({
BadSignature: BAD_TOKEN_SIGNATURE,
GroupInvitationDoesNotExist: ERROR_GROUP_INVITATION_DOES_NOT_EXIST,
})
def get(self, request, token):
"""
Responds with the serialized group invitation if an invitation with the
provided token is found.
"""
exists_queryset = User.objects.filter(username=OuterRef('email'))
group_invitation = CoreHandler().get_group_invitation_by_token(
token,
base_queryset=GroupInvitation.objects.annotate(
email_exists=Exists(exists_queryset)
)
)
return Response(UserGroupInvitationSerializer(group_invitation).data)

View file

@ -1,6 +1,11 @@
from rest_framework import serializers
from baserow.core.models import Group, GroupUser
from baserow.core.models import Group
from .users.serializers import GroupUserGroupSerializer
__all__ = ['GroupUserGroupSerializer']
class GroupSerializer(serializers.ModelSerializer):
@ -14,17 +19,6 @@ class GroupSerializer(serializers.ModelSerializer):
}
class GroupUserSerializer(serializers.ModelSerializer):
class Meta:
model = GroupUser
fields = ('order',)
def to_representation(self, instance):
data = super().to_representation(instance)
data.update(GroupSerializer(instance.group).data)
return data
class OrderGroupsSerializer(serializers.Serializer):
groups = serializers.ListField(
child=serializers.IntegerField(),

View file

@ -1,12 +1,17 @@
from django.conf.urls import url
from django.urls import path, include
from .views import GroupsView, GroupView, GroupOrderView
from .users import urls as user_urls
from .invitations import urls as invitation_urls
app_name = 'baserow.api.group'
app_name = 'baserow.api.groups'
urlpatterns = [
path('users/', include(user_urls, namespace='users')),
path('invitations/', include(invitation_urls, namespace='invitations')),
url(r'^$', GroupsView.as_view(), name='list'),
url(r'(?P<group_id>[0-9]+)/$', GroupView.as_view(), name='item'),
url(r'order/$', GroupOrderView.as_view(), name='order')
url(r'order/$', GroupOrderView.as_view(), name='order'),
]

View file

@ -0,0 +1,13 @@
from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
ERROR_GROUP_USER_DOES_NOT_EXIST = (
'ERROR_GROUP_USER_DOES_NOT_EXIST',
HTTP_404_NOT_FOUND,
'The requested group user does not exist.'
)
ERROR_GROUP_USER_ALREADY_EXISTS = (
'ERROR_GROUP_USER_ALREADY_EXISTS',
HTTP_400_BAD_REQUEST,
'The user is already a member of the group.'
)

View file

@ -0,0 +1,47 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from baserow.core.models import GroupUser
User = get_user_model()
class GroupUserSerializer(serializers.ModelSerializer):
name = serializers.SerializerMethodField()
email = serializers.SerializerMethodField()
class Meta:
model = GroupUser
fields = ('id', 'name', 'email', 'group', 'permissions', 'created_on')
def get_name(self, object):
return object.user.first_name
def get_email(self, object):
return object.user.email
class GroupUserGroupSerializer(serializers.ModelSerializer):
"""
This serializers returns all the fields that the GroupSerializer has, but also
some user specific values related to the group user relation.
"""
class Meta:
model = GroupUser
fields = ('order', 'permissions')
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
class UpdateGroupUserSerializer(serializers.ModelSerializer):
class Meta:
model = GroupUser
fields = ('permissions',)

View file

@ -0,0 +1,11 @@
from django.conf.urls import url
from .views import GroupUsersView, GroupUserView
app_name = 'baserow.api.groups.users'
urlpatterns = [
url(r'group/(?P<group_id>[0-9]+)/$', GroupUsersView.as_view(), name='list'),
url(r'(?P<group_user_id>[0-9]+)/$', GroupUserView.as_view(), name='item'),
]

View file

@ -0,0 +1,160 @@
from django.db import transaction
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from drf_spectacular.utils import extend_schema
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
from baserow.api.decorators import validate_body, map_exceptions
from baserow.api.errors import (
ERROR_GROUP_DOES_NOT_EXIST, ERROR_USER_NOT_IN_GROUP,
ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR
)
from baserow.api.groups.users.errors import ERROR_GROUP_USER_DOES_NOT_EXIST
from baserow.api.schemas import get_error_schema
from baserow.core.models import GroupUser
from baserow.core.handler import CoreHandler
from baserow.core.exceptions import (
UserNotInGroupError, UserInvalidGroupPermissionsError, GroupDoesNotExist,
GroupUserDoesNotExist
)
from .serializers import (
GroupUserSerializer, GroupUserGroupSerializer, UpdateGroupUserSerializer
)
class GroupUsersView(APIView):
@extend_schema(
parameters=[
OpenApiParameter(
name='group_id',
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description='Updates the group user related to the provided value.'
)
],
tags=['Groups'],
operation_id='list_group_users',
description=(
'Lists all the users that are in a group if the authorized user has admin '
'permissions to the related group. To add a user to a group an invitation '
'must be send first.'
),
responses={
200: GroupUserSerializer(many=True),
400: get_error_schema([
'ERROR_USER_NOT_IN_GROUP', 'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
]),
404: get_error_schema(['ERROR_GROUP_DOES_NOT_EXIST']),
}
)
@map_exceptions({
GroupDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST,
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
UserInvalidGroupPermissionsError: ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR
})
def get(self, request, group_id):
"""Responds with a list of serialized users that are part of the group."""
group = CoreHandler().get_group(group_id)
group.has_user(request.user, 'ADMIN', True)
group_users = GroupUser.objects.filter(group=group).select_related('group')
serializer = GroupUserSerializer(group_users, many=True)
return Response(serializer.data)
class GroupUserView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
parameters=[
OpenApiParameter(
name='group_user_id',
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description='Updates the group user related to the provided value.'
)
],
tags=['Groups'],
operation_id='update_group_user',
description=(
'Updates the existing group user related to the provided '
'`group_user_id` param if the authorized user has admin rights to '
'the related group.'
),
request=UpdateGroupUserSerializer,
responses={
200: GroupUserGroupSerializer,
400: get_error_schema([
'ERROR_USER_NOT_IN_GROUP',
'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR',
'ERROR_REQUEST_BODY_VALIDATION'
]),
404: get_error_schema(['ERROR_GROUP_USER_DOES_NOT_EXIST'])
},
)
@transaction.atomic
@validate_body(UpdateGroupUserSerializer)
@map_exceptions({
GroupUserDoesNotExist: ERROR_GROUP_USER_DOES_NOT_EXIST,
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
UserInvalidGroupPermissionsError: ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR
})
def patch(self, request, data, group_user_id):
"""Updates the group user if the user has admin permissions to the group."""
group_user = CoreHandler().get_group_user(
group_user_id,
base_queryset=GroupUser.objects.select_for_update()
)
group_user = CoreHandler().update_group_user(
request.user,
group_user,
**data
)
return Response(GroupUserGroupSerializer(group_user).data)
@extend_schema(
parameters=[
OpenApiParameter(
name='group_user_id',
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description='Deletes the group user related to the provided '
'value.'
)
],
tags=['Groups'],
operation_id='delete_group_user',
description=(
'Deletes a group user if the authorized user has admin rights to '
'the related group.'
),
responses={
204: None,
400: get_error_schema([
'ERROR_USER_NOT_IN_GROUP',
'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
]),
404: get_error_schema(['ERROR_GROUP_INVITATION_DOES_NOT_EXIST'])
},
)
@transaction.atomic
@map_exceptions({
GroupUserDoesNotExist: ERROR_GROUP_USER_DOES_NOT_EXIST,
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
UserInvalidGroupPermissionsError: ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR
})
def delete(self, request, group_user_id):
"""Deletes an existing group_user if the user belongs to the group."""
group_user = CoreHandler().get_group_user(
group_user_id,
base_queryset=GroupUser.objects.select_for_update()
)
CoreHandler().delete_group_user(request.user, group_user)
return Response(status=204)

View file

@ -9,13 +9,19 @@ from drf_spectacular.plumbing import build_array_type
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
from baserow.api.decorators import validate_body, map_exceptions
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP, ERROR_GROUP_DOES_NOT_EXIST
from baserow.api.errors import (
ERROR_USER_NOT_IN_GROUP, ERROR_GROUP_DOES_NOT_EXIST,
ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR
)
from baserow.api.schemas import get_error_schema
from baserow.core.models import GroupUser
from baserow.api.groups.users.serializers import GroupUserGroupSerializer
from baserow.core.models import GroupUser, Group
from baserow.core.handler import CoreHandler
from baserow.core.exceptions import UserNotInGroupError, GroupDoesNotExist
from baserow.core.exceptions import (
UserNotInGroupError, GroupDoesNotExist, UserInvalidGroupPermissionsError
)
from .serializers import GroupSerializer, GroupUserSerializer, OrderGroupsSerializer
from .serializers import GroupSerializer, OrderGroupsSerializer
from .schemas import group_user_schema
@ -41,7 +47,7 @@ class GroupsView(APIView):
"""Responds with a list of serialized groups where the user is part of."""
groups = GroupUser.objects.filter(user=request.user).select_related('group')
serializer = GroupUserSerializer(groups, many=True)
serializer = GroupUserGroupSerializer(groups, many=True)
return Response(serializer.data)
@extend_schema(
@ -63,7 +69,7 @@ class GroupsView(APIView):
"""Creates a new group for a user."""
group_user = CoreHandler().create_group(request.user, name=data['name'])
return Response(GroupUserSerializer(group_user).data)
return Response(GroupUserGroupSerializer(group_user).data)
class GroupView(APIView):
@ -87,9 +93,10 @@ class GroupView(APIView):
),
request=GroupSerializer,
responses={
200: group_user_schema,
200: GroupSerializer,
400: get_error_schema([
'ERROR_USER_NOT_IN_GROUP', 'ERROR_REQUEST_BODY_VALIDATION'
'ERROR_USER_NOT_IN_GROUP', 'ERROR_REQUEST_BODY_VALIDATION',
'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
]),
404: get_error_schema(['ERROR_GROUP_DOES_NOT_EXIST'])
}
@ -98,18 +105,22 @@ class GroupView(APIView):
@validate_body(GroupSerializer)
@map_exceptions({
GroupDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST,
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
UserInvalidGroupPermissionsError: ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR
})
def patch(self, request, data, group_id):
"""Updates the group if it belongs to a user."""
group_user = CoreHandler().get_group_user(
request.user, group_id, base_queryset=GroupUser.objects.select_for_update()
group = CoreHandler().get_group(
group_id,
base_queryset=Group.objects.select_for_update()
)
group_user.group = CoreHandler().update_group(
request.user, group_user.group, name=data['name'])
return Response(GroupUserSerializer(group_user).data)
group = CoreHandler().update_group(
request.user,
group,
name=data['name']
)
return Response(GroupSerializer(group).data)
@extend_schema(
parameters=[
@ -131,7 +142,8 @@ class GroupView(APIView):
responses={
200: group_user_schema,
400: get_error_schema([
'ERROR_USER_NOT_IN_GROUP', 'ERROR_REQUEST_BODY_VALIDATION'
'ERROR_USER_NOT_IN_GROUP', 'ERROR_REQUEST_BODY_VALIDATION',
'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
]),
404: get_error_schema(['ERROR_GROUP_DOES_NOT_EXIST'])
}
@ -139,15 +151,17 @@ class GroupView(APIView):
@transaction.atomic
@map_exceptions({
GroupDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST,
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
UserInvalidGroupPermissionsError: ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR
})
def delete(self, request, group_id):
"""Deletes an existing group if it belongs to a user."""
group_user = CoreHandler().get_group_user(
request.user, group_id, base_queryset=GroupUser.objects.select_for_update()
group = CoreHandler().get_group(
group_id,
base_queryset=Group.objects.select_for_update()
)
CoreHandler().delete_group(request.user, group_user.group)
CoreHandler().delete_group(request.user, group)
return Response(status=204)

View file

@ -1,11 +1,3 @@
from rest_framework.status import HTTP_400_BAD_REQUEST
ERROR_ALREADY_EXISTS = 'ERROR_EMAIL_ALREADY_EXISTS'
ERROR_USER_NOT_FOUND = 'ERROR_USER_NOT_FOUND'
ERROR_INVALID_OLD_PASSWORD = 'ERROR_INVALID_OLD_PASSWORD'
ERROR_HOSTNAME_IS_NOT_ALLOWED = (
'ERROR_HOSTNAME_IS_NOT_ALLOWED',
HTTP_400_BAD_REQUEST,
'Only the hostname of the web frontend is allowed.'
)

View file

@ -4,6 +4,7 @@ from rest_framework_jwt.serializers import JSONWebTokenSerializer
from django.contrib.auth import get_user_model
from django.contrib.auth.models import update_last_login
from baserow.api.groups.invitations.serializers import UserGroupInvitationSerializer
from baserow.core.user.utils import normalize_email_address
User = get_user_model()
@ -32,6 +33,11 @@ class RegisterSerializer(serializers.Serializer):
help_text='Indicates whether an authentication token should be generated and '
'be included in the response.'
)
group_invitation_token = serializers.CharField(
required=False,
help_text='If provided and valid, the user accepts the group invitation and '
'will have access to the group after signing up.'
)
class SendResetPasswordEmailBodyValidationSerializer(serializers.Serializer):
@ -76,3 +82,7 @@ class NormalizedEmailWebTokenSerializer(JSONWebTokenSerializer):
validated_data = super().validate(attrs)
update_last_login(None, validated_data['user'])
return validated_data
class DashboardSerializer(serializers.Serializer):
group_invitations = UserGroupInvitationSerializer(many=True)

View file

@ -2,7 +2,7 @@ from django.conf.urls import url
from .views import (
UserView, SendResetPasswordEmailView, ResetPasswordView, ChangePasswordView,
ObtainJSONWebToken, RefreshJSONWebToken, VerifyJSONWebToken
DashboardView, ObtainJSONWebToken, RefreshJSONWebToken, VerifyJSONWebToken
)
@ -27,5 +27,10 @@ urlpatterns = [
ChangePasswordView.as_view(),
name='change_password'
),
url(
r'^dashboard/$',
DashboardView.as_view(),
name='dashboard'
),
url(r'^$', UserView.as_view(), name='index')
]

View file

@ -16,21 +16,30 @@ from rest_framework_jwt.views import (
)
from baserow.api.decorators import map_exceptions, validate_body
from baserow.api.errors import BAD_TOKEN_SIGNATURE, EXPIRED_TOKEN_SIGNATURE
from baserow.api.errors import (
BAD_TOKEN_SIGNATURE, EXPIRED_TOKEN_SIGNATURE, ERROR_HOSTNAME_IS_NOT_ALLOWED
)
from baserow.api.groups.invitations.errors import (
ERROR_GROUP_INVITATION_DOES_NOT_EXIST, ERROR_GROUP_INVITATION_EMAIL_MISMATCH
)
from baserow.api.schemas import get_error_schema
from baserow.core.exceptions import (
BaseURLHostnameNotAllowed, GroupInvitationEmailMismatch,
GroupInvitationDoesNotExist
)
from baserow.core.models import GroupInvitation
from baserow.core.user.handler import UserHandler
from baserow.core.user.exceptions import (
UserAlreadyExist, UserNotFound, InvalidPassword, BaseURLHostnameNotAllowed
UserAlreadyExist, UserNotFound, InvalidPassword
)
from .serializers import (
RegisterSerializer, UserSerializer, SendResetPasswordEmailBodyValidationSerializer,
ResetPasswordBodyValidationSerializer, ChangePasswordBodyValidationSerializer,
NormalizedEmailWebTokenSerializer,
NormalizedEmailWebTokenSerializer, DashboardSerializer
)
from .errors import (
ERROR_ALREADY_EXISTS, ERROR_USER_NOT_FOUND, ERROR_INVALID_OLD_PASSWORD,
ERROR_HOSTNAME_IS_NOT_ALLOWED
ERROR_ALREADY_EXISTS, ERROR_USER_NOT_FOUND, ERROR_INVALID_OLD_PASSWORD
)
from .schemas import create_user_response_schema, authenticate_user_schema
@ -125,22 +134,30 @@ class UserView(APIView):
responses={
200: create_user_response_schema,
400: get_error_schema([
'ERROR_ALREADY_EXISTS',
'ERROR_REQUEST_BODY_VALIDATION'
])
'ERROR_ALREADY_EXISTS', 'ERROR_GROUP_INVITATION_DOES_NOT_EXIST'
'ERROR_REQUEST_BODY_VALIDATION', 'BAD_TOKEN_SIGNATURE'
]),
404: get_error_schema(['ERROR_GROUP_INVITATION_DOES_NOT_EXIST'])
},
auth=[None]
)
@transaction.atomic
@map_exceptions({
UserAlreadyExist: ERROR_ALREADY_EXISTS
UserAlreadyExist: ERROR_ALREADY_EXISTS,
BadSignature: BAD_TOKEN_SIGNATURE,
GroupInvitationDoesNotExist: ERROR_GROUP_INVITATION_DOES_NOT_EXIST,
GroupInvitationEmailMismatch: ERROR_GROUP_INVITATION_EMAIL_MISMATCH
})
@validate_body(RegisterSerializer)
def post(self, request, data):
"""Registers a new user."""
user = UserHandler().create_user(name=data['name'], email=data['email'],
password=data['password'])
user = UserHandler().create_user(
name=data['name'],
email=data['email'],
password=data['password'],
group_invitation_token=data.get('group_invitation_token')
)
response = {'user': UserSerializer(user).data}
@ -268,3 +285,34 @@ class ChangePasswordView(APIView):
data['new_password'])
return Response('', status=204)
class DashboardView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
tags=['User'],
operation_id='dashboard',
description=(
'Lists all the relevant user information that for example could be shown '
'on a dashboard. It will contain all the pending group invitations for '
'that user.'
),
responses={
200: DashboardSerializer
}
)
@transaction.atomic
def get(self, request):
"""Lists all the data related to the user dashboard page."""
group_invitations = GroupInvitation.objects.select_related(
'group',
'invited_by'
).filter(
email=request.user.username
)
dashboard_serializer = DashboardSerializer({
'group_invitations': group_invitations
})
return Response(dashboard_serializer.data)

View file

@ -188,6 +188,7 @@ SPECTACULAR_SETTINGS = {
{'name': 'User'},
{'name': 'User files'},
{'name': 'Groups'},
{'name': 'Group invitations'},
{'name': 'Applications'},
{'name': 'Database tables'},
{'name': 'Database table fields'},

View file

@ -76,7 +76,7 @@ class TokensView(APIView):
def post(self, request, data):
"""Creates a new token for the authorized user."""
data['group'] = CoreHandler().get_group(request.user, data.pop('group'))
data['group'] = CoreHandler().get_group(data.pop('group'))
token = TokenHandler().create_token(request.user, **data)
serializer = TokenSerializer(token)
return Response(serializer.data)

View file

@ -15,15 +15,18 @@ from .fields.field_types import (
class DatabasePlugin(Plugin):
type = 'database'
def user_created(self, user, group):
def user_created(self, user, group, group_invitation):
"""
This method is called when a new user is created. We are going to create a
database, table, view, fields and some rows here as an example for the user.
:param user: The newly created user.
:param group: The newly created group for the user.
"""
# If the user created an account in combination with a group invitation we
# don't want to create the initial data in the group because data should
# already exist.
if group_invitation:
return
core_handler = CoreHandler()
table_handler = TableHandler()
view_handler = ViewHandler()

View file

@ -2,6 +2,7 @@ from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.utils.translation import gettext as _
class BaseEmailMessage(EmailMultiAlternatives):
@ -70,3 +71,25 @@ class BaseEmailMessage(EmailMultiAlternatives):
if not self.template_name:
raise NotImplementedError('The template_name must be implement.')
return self.template_name
class GroupInvitationEmail(BaseEmailMessage):
subject = _('Group invitation')
template_name = 'baserow/core/group_invitation.html'
def __init__(self, invitation, public_accept_url, *args, **kwargs):
self.public_accept_url = public_accept_url
self.invitation = invitation
super().__init__(*args, **kwargs)
def get_subject(self):
return f'{self.invitation.invited_by.first_name} invited you to ' \
f'{self.invitation.group.name} - Baserow'
def get_context(self):
context = super().get_context()
context.update(
invitation=self.invitation,
public_accept_url=self.public_accept_url
)
return context

View file

@ -9,10 +9,36 @@ class UserNotInGroupError(Exception):
super().__init__('The user doesn\'t belong to the group', *args, **kwargs)
class UserInvalidGroupPermissionsError(Exception):
"""Raised when a user doesn't have the right permissions to the related group."""
def __init__(self, user, group, permissions, *args, **kwargs):
self.user = user
self.group = group
self.permissions = permissions
super().__init__(
f'The user {user} doesn\'t have the right permissions {permissions} to '
f'{group}.',
*args,
**kwargs
)
class GroupDoesNotExist(Exception):
"""Raised when trying to get a group that does not exist."""
class GroupUserDoesNotExist(Exception):
"""Raised when trying to get a group user that does not exist."""
class GroupUserAlreadyExists(Exception):
"""
Raised when trying to create a group user that already exists. This could also be
raised when an invitation is created for a user that is already part of the group.
"""
class ApplicationDoesNotExist(Exception):
"""Raised when trying to get an application that does not exist."""
@ -39,3 +65,22 @@ class ApplicationTypeAlreadyRegistered(InstanceTypeAlreadyRegistered):
class ApplicationTypeDoesNotExist(InstanceTypeDoesNotExist):
pass
class BaseURLHostnameNotAllowed(Exception):
"""
Raised when the provided base url is not allowed when requesting a password
reset email.
"""
class GroupInvitationDoesNotExist(Exception):
"""
Raised when the requested group invitation doesn't exist.
"""
class GroupInvitationEmailMismatch(Exception):
"""
Raised when the group invitation email is not the expected email address.
"""

View file

@ -1,21 +1,33 @@
from .models import Group, GroupUser, Application
from .exceptions import UserNotInGroupError
from urllib.parse import urlparse, urljoin
from itsdangerous import URLSafeSerializer
from django.conf import settings
from baserow.core.user.utils import normalize_email_address
from .models import (
Group, GroupUser, GroupInvitation, Application, GROUP_USER_PERMISSION_CHOICES,
GROUP_USER_PERMISSION_ADMIN
)
from .exceptions import (
GroupDoesNotExist, ApplicationDoesNotExist, BaseURLHostnameNotAllowed,
UserNotInGroupError, GroupInvitationEmailMismatch, GroupInvitationDoesNotExist,
GroupUserDoesNotExist, GroupUserAlreadyExists
)
from .utils import extract_allowed, set_allowed_attrs
from .registries import application_type_registry
from .exceptions import GroupDoesNotExist, ApplicationDoesNotExist
from .signals import (
application_created, application_updated, application_deleted, group_created,
group_updated, group_deleted
group_updated, group_deleted, group_user_updated, group_user_deleted
)
from .emails import GroupInvitationEmail
class CoreHandler:
def get_group(self, user, group_id, base_queryset=None):
def get_group(self, group_id, base_queryset=None):
"""
Selects a group with a given id from the database.
:param user: The user on whose behalf the group is requested.
:type user: User
:param group_id: The identifier of the group that must be returned.
:type group_id: int
:param base_queryset: The base queryset from where to select the group
@ -35,43 +47,8 @@ class CoreHandler:
except Group.DoesNotExist:
raise GroupDoesNotExist(f'The group with id {group_id} does not exist.')
if not group.has_user(user):
raise UserNotInGroupError(user, group)
return group
def get_group_user(self, user, group_id, base_queryset=None):
"""
Selects a group user object for the given user and group_id from the database.
:param user: The user on whose behalf the group is requested.
:type user: User
:param group_id: The identifier of the group that must be returned.
:type group_id: int
:param base_queryset: The base queryset from where to select the group user
object. This can for example be used to do a `select_related`.
:type base_queryset: Queryset
:raises GroupDoesNotExist: When the group with the provided id does not exist.
:raises UserNotInGroupError: When the user does not belong to the group.
:return: The requested group user instance of the provided group_id.
:rtype: GroupUser
"""
if not base_queryset:
base_queryset = GroupUser.objects
try:
group_user = base_queryset.select_related('group').get(
user=user, group_id=group_id
)
except GroupUser.DoesNotExist:
if Group.objects.filter(pk=group_id).exists():
raise UserNotInGroupError(user)
else:
raise GroupDoesNotExist(f'The group with id {group_id} does not exist.')
return group_user
def create_group(self, user, **kwargs):
"""
Creates a new group for an existing user.
@ -85,7 +62,12 @@ class CoreHandler:
group_values = extract_allowed(kwargs, ['name'])
group = Group.objects.create(**group_values)
last_order = GroupUser.get_last_order(user)
group_user = GroupUser.objects.create(group=group, user=user, order=last_order)
group_user = GroupUser.objects.create(
group=group,
user=user,
order=last_order,
permissions=GROUP_USER_PERMISSION_ADMIN
)
group_created.send(self, group=group, user=user)
@ -93,14 +75,14 @@ class CoreHandler:
def update_group(self, user, group, **kwargs):
"""
Updates the values of a group.
Updates the values of a group if the user on whose behalf the request is made
has admin permissions to the group.
:param user: The user on whose behalf the change is made.
:type user: User
:param group: The group instance that must be updated.
:type group: Group
:raises ValueError: If one of the provided parameters is invalid.
:raises UserNotInGroupError: When the user does not belong to the related group.
:return: The updated group
:rtype: Group
"""
@ -108,9 +90,7 @@ class CoreHandler:
if not isinstance(group, Group):
raise ValueError('The group is not an instance of Group.')
if not group.has_user(user):
raise UserNotInGroupError(user, group)
group.has_user(user, 'ADMIN', raise_error=True)
group = set_allowed_attrs(kwargs, ['name'], group)
group.save()
@ -120,21 +100,20 @@ class CoreHandler:
def delete_group(self, user, group):
"""
Deletes an existing group and related application the proper way.
Deletes an existing group and related applications if the user has admin
permissions to the group.
:param user: The user on whose behalf the delete is done.
:type: user: User
:param group: The group instance that must be deleted.
:type: group: Group
:raises ValueError: If one of the provided parameters is invalid.
:raises UserNotInGroupError: When the user does not belong to the related group.
"""
if not isinstance(group, Group):
raise ValueError('The group is not an instance of Group.')
if not group.has_user(user):
raise UserNotInGroupError(user, group)
group.has_user(user, 'ADMIN', raise_error=True)
# Load the group users before the group is deleted so that we can pass those
# along with the signal.
@ -168,6 +147,346 @@ class CoreHandler:
group_id=group_id
).update(order=index + 1)
def get_group_user(self, group_user_id, base_queryset=None):
"""
Fetches a group user object related to the provided id from the database.
:param group_user_id: The identifier of the group user that must be returned.
:type group_user_id: int
:param base_queryset: The base queryset from where to select the group user
object. This can for example be used to do a `select_related`.
:type base_queryset: Queryset
:raises GroupDoesNotExist: When the group with the provided id does not exist.
:return: The requested group user instance of the provided group_id.
:rtype: GroupUser
"""
if not base_queryset:
base_queryset = GroupUser.objects
try:
group_user = base_queryset.select_related('group').get(id=group_user_id)
except GroupUser.DoesNotExist:
raise GroupUserDoesNotExist(f'The group user with id {group_user_id} does '
f'not exist.')
return group_user
def update_group_user(self, user, group_user, **kwargs):
"""
Updates the values of an existing group user.
:param user: The user on whose behalf the group user is deleted.
:type user: User
:param group_user: The group user that must be updated.
:type group_user: GroupUser
:return: The updated group user instance.
:rtype: GroupUser
"""
if not isinstance(group_user, GroupUser):
raise ValueError('The group user is not an instance of GroupUser.')
group_user.group.has_user(user, 'ADMIN', raise_error=True)
group_user = set_allowed_attrs(kwargs, ['permissions'], group_user)
group_user.save()
group_user_updated.send(self, group_user=group_user, user=user)
return group_user
def delete_group_user(self, user, group_user):
"""
Deletes the provided group user.
:param user: The user on whose behalf the group user is deleted.
:type user: User
:param group_user: The group user that must be deleted.
:type group_user: GroupUser
"""
if not isinstance(group_user, GroupUser):
raise ValueError('The group user is not an instance of GroupUser.')
group_user.group.has_user(user, 'ADMIN', raise_error=True)
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)
def get_group_invitation_signer(self):
"""
Returns the group invitation signer. This is for example used to create a url
safe signed version of the invitation id which is used when sending a public
accept link to the user.
:return: The itsdangerous serializer.
:rtype: URLSafeSerializer
"""
return URLSafeSerializer(settings.SECRET_KEY, 'group-invite')
def send_group_invitation_email(self, invitation, base_url):
"""
Sends out a group invitation email to the user based on the provided
invitation instance.
:param invitation: The invitation instance for which the email must be send.
:type invitation: GroupInvitation
:param base_url: The base url of the frontend, where the user can accept his
invitation. The signed invitation id is appended to the URL (base_url +
'/TOKEN'). Only the PUBLIC_WEB_FRONTEND_HOSTNAME is allowed as domain name.
:type base_url: str
:raises BaseURLHostnameNotAllowed: When the host name of the base_url is not
allowed.
"""
parsed_base_url = urlparse(base_url)
if parsed_base_url.hostname != settings.PUBLIC_WEB_FRONTEND_HOSTNAME:
raise BaseURLHostnameNotAllowed(
f'The hostname {parsed_base_url.netloc} is not allowed.'
)
signer = self.get_group_invitation_signer()
signed_invitation_id = signer.dumps(invitation.id)
if not base_url.endswith('/'):
base_url += '/'
public_accept_url = urljoin(base_url, signed_invitation_id)
email = GroupInvitationEmail(
invitation,
public_accept_url,
to=[invitation.email]
)
email.send()
def get_group_invitation_by_token(self, token, base_queryset=None):
"""
Returns the group invitation instance if a valid signed token of the id is
provided. It can be signed using the signer returned by the
`get_group_invitation_signer` method.
:param token: The signed invitation id of related to the group invitation
that must be fetched. Must be signed using the signer returned by the
`get_group_invitation_signer`.
:type token: str
:param base_queryset: The base queryset from where to select the invitation.
This can for example be used to do a `select_related`.
:type base_queryset: Queryset
:raises BadSignature: When the provided token has a bad signature.
:raises GroupInvitationDoesNotExist: If the invitation does not exist.
:return: The requested group invitation instance related to the provided token.
:rtype: GroupInvitation
"""
signer = self.get_group_invitation_signer()
group_invitation_id = signer.loads(token)
if not base_queryset:
base_queryset = GroupInvitation.objects
try:
group_invitation = base_queryset.select_related(
'group', 'invited_by'
).get(id=group_invitation_id)
except GroupInvitation.DoesNotExist:
raise GroupInvitationDoesNotExist(
f'The group invitation with id {group_invitation_id} does not exist.'
)
return group_invitation
def get_group_invitation(self, user, group_invitation_id, base_queryset=None):
"""
Selects a group invitation with a given id from the database.
:param group_invitation_id: The identifier of the invitation that must be
returned.
:type group_invitation_id: int
:param base_queryset: The base queryset from where to select the invitation.
This can for example be used to do a `select_related`.
:type base_queryset: Queryset
:raises GroupInvitationDoesNotExist: If the invitation does not exist.
:return: The requested field instance of the provided id.
:rtype: GroupInvitation
"""
if not base_queryset:
base_queryset = GroupInvitation.objects
try:
group_invitation = base_queryset.select_related('group', 'invited_by').get(
id=group_invitation_id
)
except GroupInvitation.DoesNotExist:
raise GroupInvitationDoesNotExist(
f'The group invitation with id {group_invitation_id} does not exist.'
)
group_invitation.group.has_user(user, 'ADMIN', raise_error=True)
return group_invitation
def create_group_invitation(self, user, group, email, permissions, message,
base_url):
"""
Creates a new group invitation for the given email address and sends out an
email containing the invitation.
:param user: The user on whose behalf the invitation is created.
:type user: User
:param group: The group for which the user is invited.
:type group: Group
:param email: The email address of the person that is invited to the group.
Can be an existing or not existing user.
:type email: str
:param permissions: The group permissions that the user will get once he has
accepted the invitation.
:type permissions: str
:param message: A custom message that will be included in the invitation email.
:type message: str
:param base_url: The base url of the frontend, where the user can accept his
invitation. The signed invitation id is appended to the URL (base_url +
'/TOKEN'). Only the PUBLIC_WEB_FRONTEND_HOSTNAME is allowed as domain name.
:type base_url: str
:raises ValueError: If the provided permissions are not allowed.
:raises UserInvalidGroupPermissionsError: If the user does not belong to the
group or doesn't have right permissions in the group.
:return: The created group invitation.
:rtype: GroupInvitation
"""
group.has_user(user, 'ADMIN', raise_error=True)
if permissions not in dict(GROUP_USER_PERMISSION_CHOICES):
raise ValueError('Incorrect permissions provided.')
email = normalize_email_address(email)
if GroupUser.objects.filter(group=group, user__email=email).exists():
raise GroupUserAlreadyExists(f'The user {email} is already part of the '
f'group.')
invitation, created = GroupInvitation.objects.update_or_create(
group=group,
email=email,
defaults={
'message': message,
'permissions': permissions,
'invited_by': user
}
)
self.send_group_invitation_email(invitation, base_url)
return invitation
def update_group_invitation(self, user, invitation, permissions):
"""
Updates the permissions of an existing invitation if the user has ADMIN
permissions to the related group.
:param user: The user on whose behalf the invitation is updated.
:type user: User
:param invitation: The invitation that must be updated.
:type invitation: GroupInvitation
:param permissions: The new permissions of the invitation that the user must
has after accepting.
:type permissions: str
:raises ValueError: If the provided permissions is not allowed.
:raises UserInvalidGroupPermissionsError: If the user does not belong to the
group or doesn't have right permissions in the group.
:return: The updated group permissions instance.
:rtype: GroupInvitation
"""
invitation.group.has_user(user, 'ADMIN', raise_error=True)
if permissions not in dict(GROUP_USER_PERMISSION_CHOICES):
raise ValueError('Incorrect permissions provided.')
invitation.permissions = permissions
invitation.save()
return invitation
def delete_group_invitation(self, user, invitation):
"""
Deletes an existing group invitation if the user has ADMIN permissions to the
related group.
:param user: The user on whose behalf the invitation is deleted.
:type user: User
:param invitation: The invitation that must be deleted.
:type invitation: GroupInvitation
:raises UserInvalidGroupPermissionsError: If the user does not belong to the
group or doesn't have right permissions in the group.
"""
invitation.group.has_user(user, 'ADMIN', raise_error=True)
invitation.delete()
def reject_group_invitation(self, user, invitation):
"""
Rejects a group invitation by deleting the invitation so that can't be reused
again. It can only be rejected if the invitation was addressed to the email
address of the user.
:param user: The user who wants to reject the invitation.
:type user: User
:param invitation: The invitation that must be rejected.
:type invitation: GroupInvitation
:raises GroupInvitationEmailMismatch: If the invitation email does not match
the one of the user.
"""
if user.username != invitation.email:
raise GroupInvitationEmailMismatch(
'The email address of the invitation does not match the one of the '
'user.'
)
invitation.delete()
def accept_group_invitation(self, user, invitation):
"""
Accepts a group invitation by adding the user to the correct group with the
right permissions. It can only be accepted if the invitation was addressed to
the email address of the user. Because the invitation has been accepted it
can then be deleted. If the user is already a member of the group then the
permissions are updated.
:param user: The user who has accepted the invitation.
:type: user: User
:param invitation: The invitation that must be accepted.
:type invitation: GroupInvitation
:raises GroupInvitationEmailMismatch: If the invitation email does not match
the one of the user.
:return: The group user relationship related to the invite.
:rtype: GroupUser
"""
if user.username != invitation.email:
raise GroupInvitationEmailMismatch(
'The email address of the invitation does not match the one of the '
'user.'
)
group_user, created = GroupUser.objects.update_or_create(
user=user,
group=invitation.group,
defaults={
'order': GroupUser.get_last_order(user),
'permissions': invitation.permissions
}
)
invitation.delete()
return group_user
def get_application(self, user, application_id, base_queryset=None):
"""
Selects an application with a given id from the database.

View file

@ -0,0 +1,123 @@
# Generated by Django 2.2.11 on 2021-01-26 19:50
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def forward(apps, schema_editor):
from baserow.core.models import GROUP_USER_PERMISSION_ADMIN
GroupUser = apps.get_model('core', 'GroupUser')
GroupUser.objects.all().update(permissions=GROUP_USER_PERMISSION_ADMIN)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('core', '0003_auto_20201215_2047'),
]
operations = [
migrations.AddField(
model_name='groupuser',
name='permissions',
field=models.CharField(
choices=[
('ADMIN', 'Admin'),
('MEMBER', 'Member')
],
default='MEMBER',
help_text='The permissions that the user has within the group.',
max_length=32
),
),
migrations.AlterField(
model_name='groupuser',
name='group',
field=models.ForeignKey(
help_text='The group that the user has access to.',
on_delete=django.db.models.deletion.CASCADE,
to='core.Group'
),
),
migrations.AlterField(
model_name='groupuser',
name='order',
field=models.PositiveIntegerField(
help_text='Unique order that the group has for the user.'
),
),
migrations.AlterField(
model_name='groupuser',
name='permissions',
field=models.CharField(
choices=[('ADMIN', 'Admin'), ('MEMBER', 'Member')],
default='MEMBER',
help_text='The permissions that the user has within the group.',
max_length=32
),
),
migrations.AlterField(
model_name='groupuser',
name='user',
field=models.ForeignKey(
help_text='The user that has access to the group.',
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL
),
),
migrations.CreateModel(
name='GroupInvitation',
fields=[
('id', models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID'
)),
('email', models.EmailField(
db_index=True,
max_length=254,
help_text='The email address of the user that the invitation is '
'meant for. Only a user with that email address can '
'accept it.'
)),
('permissions', models.CharField(
choices=[
('ADMIN', 'Admin'),
('MEMBER', 'Member')
],
default='MEMBER',
max_length=32,
help_text='The permissions that the user is going to get within '
'the group after accepting the invitation.'
)),
('message', models.TextField(
blank=True,
help_text='An optional message that the invitor can provide. This '
'will be visible to the receiver of the invitation.'
)),
('group', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='core.Group',
help_text='The group that the user will get access to once the '
'invitation is accepted.'
)),
('invited_by', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
help_text='The user that created the invitation.'
)),
('created_on', models.DateTimeField(auto_now_add=True)),
('updated_on', models.DateTimeField(auto_now=True))
],
options={'ordering': ('id',)}
),
migrations.AlterUniqueTogether(
name='groupuser',
unique_together={('user', 'group')},
),
migrations.RunPython(forward, migrations.RunPython.noop),
]

View file

@ -8,6 +8,8 @@ from .managers import GroupQuerySet
from .mixins import (
OrderableMixin, PolymorphicContentTypeMixin, CreatedAndUpdatedOnMixin
)
from .exceptions import UserNotInGroupError, UserInvalidGroupPermissionsError
__all__ = ['UserFile']
@ -15,6 +17,16 @@ __all__ = ['UserFile']
User = get_user_model()
# The difference between an admin and member right now is that an admin has
# permissions to update, delete and manage the members of a group.
GROUP_USER_PERMISSION_ADMIN = 'ADMIN'
GROUP_USER_PERMISSION_MEMBER = 'MEMBER'
GROUP_USER_PERMISSION_CHOICES = (
(GROUP_USER_PERMISSION_ADMIN, 'Admin'),
(GROUP_USER_PERMISSION_MEMBER, 'Member')
)
def get_default_application_content_type():
return ContentType.objects.get_for_model(Application)
@ -25,21 +37,74 @@ class Group(CreatedAndUpdatedOnMixin, models.Model):
objects = GroupQuerySet.as_manager()
def has_user(self, user):
"""Returns true is the user belongs to the group."""
def has_user(self, user, permissions=None, raise_error=False):
"""
Checks if the provided user belongs to the group.
return self.users.filter(id=user.id).exists()
:param user: The user that must be in the group.
:type user: User
:param permissions: One or multiple permissions can optionally be provided
and if so, the user must have one of those permissions.
:type permissions: None, str or list
:param raise_error: If True an error will be raised when the user does not
belong to the group or doesn't have the right permissions.
:type raise_error: bool
:raises UserNotInGroupError: If the user does not belong to the group.
:raises UserInvalidGroupPermissionsError: If the user does belong to the group,
but doesn't have the right permissions.
:return: Indicates if the user belongs to the group.
:rtype: bool
"""
if permissions and not isinstance(permissions, list):
permissions = [permissions]
queryset = GroupUser.objects.filter(
user_id=user.id,
group_id=self.id
)
if raise_error:
try:
group_user = queryset.get()
except GroupUser.DoesNotExist:
raise UserNotInGroupError(user, self)
if permissions is not None and group_user.permissions not in permissions:
raise UserInvalidGroupPermissionsError(user, self, permissions)
else:
if permissions is not None:
queryset = queryset.filter(permissions__in=permissions)
return queryset.exists()
def __str__(self):
return f'<Group id={self.id}, name={self.name}>'
class GroupUser(CreatedAndUpdatedOnMixin, OrderableMixin, models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
group = models.ForeignKey(Group, on_delete=models.CASCADE)
order = models.PositiveIntegerField()
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
help_text='The user that has access to the group.'
)
group = models.ForeignKey(
Group,
on_delete=models.CASCADE,
help_text='The group that the user has access to.'
)
order = models.PositiveIntegerField(
help_text='Unique order that the group has for the user.'
)
permissions = models.CharField(
default=GROUP_USER_PERMISSION_MEMBER,
max_length=32,
choices=GROUP_USER_PERMISSION_CHOICES,
help_text='The permissions that the user has within the group.'
)
class Meta:
unique_together = [['user', 'group']]
ordering = ('order',)
@classmethod
@ -48,6 +113,40 @@ class GroupUser(CreatedAndUpdatedOnMixin, OrderableMixin, models.Model):
return cls.get_highest_order_of_queryset(queryset) + 1
class GroupInvitation(CreatedAndUpdatedOnMixin, models.Model):
group = models.ForeignKey(
Group,
on_delete=models.CASCADE,
help_text='The group that the user will get access to once the invitation is '
'accepted.'
)
invited_by = models.ForeignKey(
User,
on_delete=models.CASCADE,
help_text='The user that created the invitation.'
)
email = models.EmailField(
db_index=True,
help_text='The email address of the user that the invitation is meant for. '
'Only a user with that email address can accept it.'
)
permissions = models.CharField(
default=GROUP_USER_PERMISSION_MEMBER,
max_length=32,
choices=GROUP_USER_PERMISSION_CHOICES,
help_text='The permissions that the user is going to get within the group '
'after accepting the invitation.'
)
message = models.TextField(
blank=True,
help_text='An optional message that the invitor can provide. This will be '
'visible to the receiver of the invitation.'
)
class Meta:
ordering = ('id',)
class Application(CreatedAndUpdatedOnMixin, OrderableMixin,
PolymorphicContentTypeMixin, models.Model):
group = models.ForeignKey(Group, on_delete=models.CASCADE)

View file

@ -66,14 +66,19 @@ class Plugin(APIUrlsInstanceMixin, Instance):
return []
def user_created(self, user, group):
def user_created(self, user, group, group_invitation):
"""
A hook that is called after a new user has been created. This is the place to
create some data the user can start with. A group has already been created
for the user to that one is passed as a parameter.
:param user: The newly created user.
:type user: User
:param group: The newly created group for the user.
:type group: Group
:param group_invitation: Is provided if the user has signed up using a valid
group invitation token.
:type group_invitation: GroupInvitation
"""

View file

@ -5,6 +5,9 @@ group_created = Signal()
group_updated = Signal()
group_deleted = Signal()
group_user_updated = Signal()
group_user_deleted = Signal()
application_created = Signal()
application_updated = Signal()
application_deleted = Signal()

View file

@ -0,0 +1,29 @@
{% extends 'baserow/base.html' %}
{% block body %}
<mj-section>
<mj-column>
<mj-text mj-class="title">Invitation</mj-text>
<mj-text mj-class="text">
<strong>{{ invitation.invited_by.first_name }}</strong> has invited you to
collaborate on <strong>{{ invitation.group.name }}</strong>.
</mj-text>
{% if invitation.message %}
<mj-text mj-class="text">
"{{ invitation.message }}"
</mj-text>
{% endif %}
<mj-button mj-class="button" href="{{ public_accept_url }}">
Accept invitation
</mj-button>
<mj-text mj-class="button-url">
{{ public_accept_url }}
</mj-text>
<mj-text mj-class="text">
Baserow is an open source no-code database tool which allows you to collaborate
on projects, customers and more. It gives you the powers of a developer without
leaving your browser.
</mj-text>
</mj-column>
</mj-section>
{% endblock %}

View file

@ -6,8 +6,8 @@
<mj-text mj-class="title">Reset password</mj-text>
<mj-text mj-class="text">
A password reset was requested for your account ({{ user.username }}) on
Baserow ({{ public_web_frontend_hostname }}). If you did not authorize this, you
may simply ignore this email.
Baserow ({{ public_web_frontend_hostname }}). If you did not authorize this,
you may simply ignore this email.
</mj-text>
<mj-text mj-class="text" padding-bottom="20px">
To continue with your password reset, simply click the button below, and you
@ -20,6 +20,11 @@
<mj-text mj-class="button-url">
{{ reset_url }}
</mj-text>
<mj-text mj-class="text">
Baserow is an open source no-code database tool which allows you to collaborate
on projects, customers and more. It gives you the powers of a developer without
leaving your browser.
</mj-text>
</mj-column>
</mj-section>
{% endblock %}

View file

@ -5,7 +5,7 @@ from baserow.core.emails import BaseEmailMessage
class ResetPasswordEmail(BaseEmailMessage):
subject = _('Reset password')
subject = _('Reset password - Baserow')
template_name = 'baserow/core/user/reset_password.html'
def __init__(self, user, reset_url, *args, **kwargs):

View file

@ -8,10 +8,3 @@ class UserAlreadyExist(Exception):
class InvalidPassword(Exception):
"""Raised when the provided password is incorrect."""
class BaseURLHostnameNotAllowed(Exception):
"""
Raised when the provided base url is not allowed when requesting a password
reset email.
"""

View file

@ -3,14 +3,16 @@ from itsdangerous import URLSafeTimedSerializer
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import IntegrityError
from django.db.models import Q
from baserow.core.handler import CoreHandler
from baserow.core.registries import plugin_registry
from .exceptions import (
UserAlreadyExist, UserNotFound, InvalidPassword, BaseURLHostnameNotAllowed
from baserow.core.exceptions import (
BaseURLHostnameNotAllowed
)
from baserow.core.exceptions import GroupInvitationEmailMismatch
from .exceptions import UserAlreadyExist, UserNotFound, InvalidPassword
from .emails import ResetPasswordEmail
from .utils import normalize_email_address
@ -51,35 +53,62 @@ class UserHandler:
except User.DoesNotExist:
raise UserNotFound('The user with the provided parameters is not found.')
def create_user(self, name, email, password):
def create_user(self, name, email, password, group_invitation_token=None):
"""
Creates a new user with the provided information and creates a new group and
application for him.
application for him. If the optional group invitation is provided then the user
joins that group without creating a new one.
:param name: The name of the new user.
:type name: str
:param email: The e-mail address of the user, this is also the username.
:type email: str
:param password: The password of the user.
:type password: str
:param group_invitation_token: If provided and valid, the invitation will be
accepted and and initial group will not be created.
:type group_invitation_token: str
:raises: UserAlreadyExist: When a user with the provided username (email)
already exists.
:raises GroupInvitationEmailMismatch: If the group invitation email does not
match the one of the user.
:return: The user object.
:rtype: User
"""
try:
email = normalize_email_address(email)
user = User(first_name=name, email=email, username=email)
user.set_password(password)
user.save()
except IntegrityError:
email = normalize_email_address(email)
if User.objects.filter(Q(email=email) | Q(username=email)).exists():
raise UserAlreadyExist(f'A user with username {email} already exists.')
# Insert some initial data for the newly created user.
core_handler = CoreHandler()
group_user = core_handler.create_group(user=user, name=f"{name}'s group")
group_invitation = None
group_user = None
if group_invitation_token:
group_invitation = core_handler.get_group_invitation_by_token(
group_invitation_token
)
if email != group_invitation.email:
raise GroupInvitationEmailMismatch(
'The email address of the invitation does not match the one of the '
'user.'
)
user = User(first_name=name, email=email, username=email)
user.set_password(password)
user.save()
if group_invitation_token:
group_user = core_handler.accept_group_invitation(user, group_invitation)
if not group_user:
group_user = core_handler.create_group(user=user, name=f"{name}'s group")
# Call the user_created method for each plugin that is un the registry.
for plugin in plugin_registry.registry.values():
plugin.user_created(user, group_user.group)
plugin.user_created(user, group_user.group, group_invitation)
return user

View file

@ -1,7 +1,7 @@
from django.dispatch import receiver
from django.db import transaction
from baserow.api.groups.serializers import GroupSerializer
from baserow.api.groups.serializers import GroupSerializer, GroupUserGroupSerializer
from baserow.api.applications.serializers import get_application_serializer
from baserow.core import signals
@ -45,6 +45,31 @@ def group_deleted(sender, group_id, group, group_users, 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],
{
'type': 'group_updated',
'group_id': group_user.group_id,
'group': GroupUserGroupSerializer(group_user).data
},
getattr(user, 'web_socket_id', None)
))
@receiver(signals.group_user_deleted)
def group_user_deleted(sender, group_user, user, **kwargs):
transaction.on_commit(lambda: broadcast_to_users.delay(
[group_user.user_id],
{
'type': 'group_deleted',
'group_id': group_user.group_id
},
getattr(user, 'web_socket_id', None)
))
@receiver(signals.application_created)
def application_created(sender, application, user, type_name, **kwargs):
transaction.on_commit(lambda: broadcast_to_group.delay(

View file

@ -0,0 +1,562 @@
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 django.shortcuts import reverse
from baserow.core.handler import CoreHandler
from baserow.core.models import GroupUser, GroupInvitation
@pytest.mark.django_db
def test_list_group_invitations(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')
group_1 = data_fixture.create_group(user=user_1)
group_2 = data_fixture.create_group()
data_fixture.create_user_group(group=group_1, user=user_2, permissions='MEMBER')
with freeze_time('2020-01-02 12:00'):
invitation_1 = data_fixture.create_group_invitation(
group=group_1, invited_by=user_1, email='test3@test.nl',
permissions='MEMBER', message='Test bericht 1'
)
invitation_2 = data_fixture.create_group_invitation(
group=group_1, invited_by=user_1, email='test4@test.nl', permissions='ADMIN',
message='Test bericht 2'
)
response = api_client.get(
reverse('api:groups:invitations:list', kwargs={'group_id': 999999}),
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()['error'] == 'ERROR_GROUP_DOES_NOT_EXIST'
response = api_client.get(
reverse('api:groups:invitations:list', kwargs={'group_id': group_2.id}),
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_USER_NOT_IN_GROUP'
response = api_client.get(
reverse('api:groups:invitations:list', kwargs={'group_id': group_1.id}),
HTTP_AUTHORIZATION=f'JWT {token_2}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
assert response.json()['detail'] == 'You need [\'ADMIN\'] permissions.'
response = api_client.get(
reverse('api:groups:invitations:list', kwargs={'group_id': group_1.id}),
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert len(response_json) == 2
assert response_json[0]['id'] == invitation_1.id
assert response_json[0]['group'] == invitation_1.group_id
assert response_json[0]['email'] == 'test3@test.nl'
assert response_json[0]['permissions'] == 'MEMBER'
assert response_json[0]['message'] == 'Test bericht 1'
assert response_json[0]['created_on'] == '2020-01-02T12:00:00Z'
assert response_json[1]['id'] == invitation_2.id
@pytest.mark.django_db
def test_create_group_invitation(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')
user_3, token_3 = data_fixture.create_user_and_token(email='test3@test.nl')
group_1 = data_fixture.create_group(user=user_1)
data_fixture.create_user_group(group=group_1, user=user_3, permissions='MEMBER')
response = api_client.post(
reverse('api:groups:invitations:list', kwargs={'group_id': 99999}),
{
'email': 'test@test.nl',
'permissions': 'ADMIN',
'message': 'Test',
'base_url': 'http://localhost:3000/invite'
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_404_NOT_FOUND
assert response_json['error'] == 'ERROR_GROUP_DOES_NOT_EXIST'
response = api_client.post(
reverse('api:groups:invitations:list', kwargs={'group_id': group_1.id}),
{
'email': 'test@test.nl',
'permissions': 'ADMIN',
'message': 'Test',
'base_url': 'http://localhost:3000/invite'
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token_2}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_NOT_IN_GROUP'
response = api_client.post(
reverse('api:groups:invitations:list', kwargs={'group_id': group_1.id}),
{
'email': user_3.email,
'permissions': 'ADMIN',
'message': 'Test',
'base_url': 'http://localhost:3000/invite'
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_GROUP_USER_ALREADY_EXISTS'
response = api_client.post(
reverse('api:groups:invitations:list', kwargs={'group_id': group_1.id}),
{
'email': 'test@test.nl',
'permissions': 'ADMIN',
'message': 'Test',
'base_url': 'http://localhost:3000/invite'
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token_3}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
response = api_client.post(
reverse('api:groups:invitations:list', kwargs={'group_id': group_1.id}),
{
'email': 'NO_EMAIL',
'permissions': 'NOT_EXISTING',
'message': '',
'base_url': 'http://localhost:3000/invite'
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
assert response_json['detail']['email'][0]['code'] == 'invalid'
assert response_json['detail']['permissions'][0]['code'] == 'invalid_choice'
assert 'message' not in response_json['detail']
response = api_client.post(
reverse('api:groups:invitations:list', kwargs={'group_id': group_1.id}),
{
'email': 'test@test.nl',
'permissions': 'ADMIN',
'message': 'Test',
'base_url': 'http://test.nl:3000/invite'
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_HOSTNAME_IS_NOT_ALLOWED'
response = api_client.post(
reverse('api:groups:invitations:list', kwargs={'group_id': group_1.id}),
{
'email': 'test0@test.nl',
'permissions': 'ADMIN',
'message': 'Test',
'base_url': 'http://localhost:3000/invite'
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
invitation = GroupInvitation.objects.all().first()
assert response_json['id'] == invitation.id
assert response_json['group'] == invitation.group_id
assert response_json['email'] == 'test0@test.nl'
assert response_json['permissions'] == 'ADMIN'
assert response_json['message'] == 'Test'
assert 'created_on' in response_json
@pytest.mark.django_db
def test_get_group_invitation(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')
user_3, token_3 = data_fixture.create_user_and_token(email='test3@test.nl')
group_1 = data_fixture.create_group()
data_fixture.create_user_group(group=group_1, user=user_1, permissions='ADMIN')
data_fixture.create_user_group(group=group_1, user=user_2, permissions='MEMBER')
invitation = data_fixture.create_group_invitation(
invited_by=user_1,
group=group_1,
email='test0@test.nl',
permissions='ADMIN',
message='TEst'
)
response = api_client.get(
reverse('api:groups:invitations:item', kwargs={'group_invitation_id': 99999}),
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_404_NOT_FOUND
assert response_json['error'] == 'ERROR_GROUP_INVITATION_DOES_NOT_EXIST'
response = api_client.get(
reverse(
'api:groups:invitations:item',
kwargs={'group_invitation_id': invitation.id}
),
HTTP_AUTHORIZATION=f'JWT {token_3}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_NOT_IN_GROUP'
response = api_client.get(
reverse(
'api:groups:invitations:item',
kwargs={'group_invitation_id': invitation.id}
),
HTTP_AUTHORIZATION=f'JWT {token_2}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
response = api_client.get(
reverse(
'api:groups:invitations:item',
kwargs={'group_invitation_id': invitation.id}
),
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['id'] == invitation.id
assert response_json['group'] == invitation.group_id
assert response_json['email'] == invitation.email
assert response_json['permissions'] == invitation.permissions
assert response_json['message'] == invitation.message
assert 'created_on' in response_json
@pytest.mark.django_db
def test_update_group_invitation(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')
user_3, token_3 = data_fixture.create_user_and_token(email='test3@test.nl')
group_1 = data_fixture.create_group()
data_fixture.create_user_group(group=group_1, user=user_1, permissions='ADMIN')
data_fixture.create_user_group(group=group_1, user=user_2, permissions='MEMBER')
invitation = data_fixture.create_group_invitation(
invited_by=user_1,
group=group_1,
email='test0@test.nl',
permissions='ADMIN',
message='TEst'
)
response = api_client.patch(
reverse('api:groups:invitations:item', kwargs={'group_invitation_id': 99999}),
{'permissions': 'MEMBER'},
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_404_NOT_FOUND
assert response_json['error'] == 'ERROR_GROUP_INVITATION_DOES_NOT_EXIST'
response = api_client.patch(
reverse(
'api:groups:invitations:item',
kwargs={'group_invitation_id': invitation.id}
),
{'permissions': 'MEMBER'},
HTTP_AUTHORIZATION=f'JWT {token_3}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_NOT_IN_GROUP'
response = api_client.patch(
reverse(
'api:groups:invitations:item',
kwargs={'group_invitation_id': invitation.id}
),
{'permissions': 'MEMBER'},
HTTP_AUTHORIZATION=f'JWT {token_2}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
response = api_client.patch(
reverse(
'api:groups:invitations:item',
kwargs={'group_invitation_id': invitation.id}
),
{'permissions': 'NOT_EXISTING'},
format='json',
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
assert response_json['detail']['permissions'][0]['code'] == 'invalid_choice'
response = api_client.patch(
reverse(
'api:groups:invitations:item',
kwargs={'group_invitation_id': invitation.id}
),
{'permissions': 'MEMBER'},
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['id'] == invitation.id
assert response_json['group'] == invitation.group_id
assert response_json['email'] == invitation.email
assert response_json['permissions'] == 'MEMBER'
assert response_json['message'] == invitation.message
assert 'created_on' in response_json
response = api_client.patch(
reverse(
'api:groups:invitations:item',
kwargs={'group_invitation_id': invitation.id}
),
{
'email': 'should.be@ignored.nl',
'permissions': 'ADMIN',
'message': 'Should be ignored',
'base_url': 'http://should.be.ignored:3000/invite'
},
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['id'] == invitation.id
assert response_json['group'] == invitation.group_id
assert response_json['email'] == invitation.email
assert response_json['permissions'] == 'ADMIN'
assert response_json['message'] == invitation.message
@pytest.mark.django_db
def test_delete_group_invitation(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')
user_3, token_3 = data_fixture.create_user_and_token(email='test3@test.nl')
group_1 = data_fixture.create_group()
data_fixture.create_user_group(group=group_1, user=user_1, permissions='ADMIN')
data_fixture.create_user_group(group=group_1, user=user_2, permissions='MEMBER')
invitation = data_fixture.create_group_invitation(
invited_by=user_1,
group=group_1,
email='test0@test.nl',
permissions='ADMIN',
message='TEst'
)
response = api_client.delete(
reverse('api:groups:invitations:item', kwargs={'group_invitation_id': 99999}),
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_404_NOT_FOUND
assert response_json['error'] == 'ERROR_GROUP_INVITATION_DOES_NOT_EXIST'
response = api_client.delete(
reverse(
'api:groups:invitations:item',
kwargs={'group_invitation_id': invitation.id}
),
HTTP_AUTHORIZATION=f'JWT {token_3}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_NOT_IN_GROUP'
response = api_client.delete(
reverse(
'api:groups:invitations:item',
kwargs={'group_invitation_id': invitation.id}
),
HTTP_AUTHORIZATION=f'JWT {token_2}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
response = api_client.delete(
reverse(
'api:groups:invitations:item',
kwargs={'group_invitation_id': invitation.id}
),
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
assert response.status_code == HTTP_204_NO_CONTENT
assert GroupInvitation.objects.all().count() == 0
@pytest.mark.django_db
def test_accept_group_invitation(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')
group_1 = data_fixture.create_group()
invitation = data_fixture.create_group_invitation(
invited_by=user_1,
group=group_1,
email='test1@test.nl',
permissions='ADMIN',
message='Test'
)
response = api_client.post(
reverse('api:groups:invitations:accept', kwargs={'group_invitation_id': 99999}),
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_404_NOT_FOUND
assert response_json['error'] == 'ERROR_GROUP_INVITATION_DOES_NOT_EXIST'
response = api_client.post(
reverse(
'api:groups:invitations:accept',
kwargs={'group_invitation_id': invitation.id}
),
HTTP_AUTHORIZATION=f'JWT {token_2}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_GROUP_INVITATION_EMAIL_MISMATCH'
response = api_client.post(
reverse(
'api:groups:invitations:accept',
kwargs={'group_invitation_id': invitation.id}
),
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert 'id' in response_json
assert response_json['permissions'] == 'ADMIN'
assert response_json['name'] == group_1.name
assert response_json['order'] == 1
assert GroupInvitation.objects.all().count() == 0
assert GroupUser.objects.all().count() == 1
@pytest.mark.django_db
def test_reject_group_invitation(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')
group_1 = data_fixture.create_group()
invitation = data_fixture.create_group_invitation(
invited_by=user_1,
group=group_1,
email='test1@test.nl',
permissions='ADMIN',
message='Test'
)
response = api_client.post(
reverse('api:groups:invitations:reject', kwargs={'group_invitation_id': 99999}),
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_404_NOT_FOUND
assert response_json['error'] == 'ERROR_GROUP_INVITATION_DOES_NOT_EXIST'
response = api_client.post(
reverse(
'api:groups:invitations:reject',
kwargs={'group_invitation_id': invitation.id}
),
HTTP_AUTHORIZATION=f'JWT {token_2}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_GROUP_INVITATION_EMAIL_MISMATCH'
response = api_client.post(
reverse(
'api:groups:invitations:reject',
kwargs={'group_invitation_id': invitation.id}
),
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
assert response.status_code == HTTP_204_NO_CONTENT
assert GroupInvitation.objects.all().count() == 0
assert GroupUser.objects.all().count() == 0
@pytest.mark.django_db
def test_get_group_invitation_by_token(api_client, data_fixture):
data_fixture.create_user(email='test1@test.nl')
invitation = data_fixture.create_group_invitation(
email='test0@test.nl',
permissions='ADMIN',
message='TEst'
)
invitation_2 = data_fixture.create_group_invitation(
email='test1@test.nl',
permissions='ADMIN',
)
handler = CoreHandler()
signer = handler.get_group_invitation_signer()
response = api_client.get(
reverse('api:groups:invitations:token', kwargs={'token': 'INVALID'}),
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'BAD_TOKEN_SIGNATURE'
response = api_client.get(
reverse('api:groups:invitations:token', kwargs={'token': signer.dumps(99999)}),
)
response_json = response.json()
assert response.status_code == HTTP_404_NOT_FOUND
assert response_json['error'] == 'ERROR_GROUP_INVITATION_DOES_NOT_EXIST'
response = api_client.get(
reverse(
'api:groups:invitations:token',
kwargs={'token': signer.dumps(invitation.id)}
),
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['id'] == invitation.id
assert response_json['invited_by'] == invitation.invited_by.first_name
assert response_json['group'] == invitation.group.name
assert response_json['email'] == invitation.email
assert response_json['message'] == invitation.message
assert response_json['email_exists'] is False
assert 'created_on' in response_json
response = api_client.get(
reverse(
'api:groups:invitations:token',
kwargs={'token': signer.dumps(invitation_2.id)}
),
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['id'] == invitation_2.id
assert response_json['email_exists'] is True

View file

@ -0,0 +1,190 @@
import pytest
from rest_framework.status import (
HTTP_200_OK, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
)
from django.shortcuts import reverse
from baserow.core.models import GroupUser
@pytest.mark.django_db
def test_list_group_users(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')
user_3, token_3 = data_fixture.create_user_and_token(email='test3@test.nl')
group_1 = data_fixture.create_group()
data_fixture.create_user_group(group=group_1, user=user_1, permissions='ADMIN')
data_fixture.create_user_group(group=group_1, user=user_2, permissions='MEMBER')
response = api_client.get(
reverse('api:groups:users:list', kwargs={'group_id': 99999}),
{'permissions': 'MEMBER'},
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_404_NOT_FOUND
assert response_json['error'] == 'ERROR_GROUP_DOES_NOT_EXIST'
response = api_client.get(
reverse(
'api:groups:users:list',
kwargs={'group_id': group_1.id}
),
HTTP_AUTHORIZATION=f'JWT {token_3}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_NOT_IN_GROUP'
response = api_client.get(
reverse(
'api:groups:users:list',
kwargs={'group_id': group_1.id}
),
HTTP_AUTHORIZATION=f'JWT {token_2}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
response = api_client.get(
reverse(
'api:groups:users:list',
kwargs={'group_id': group_1.id}
),
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json) == 2
assert response_json[0]['permissions'] == 'ADMIN'
assert response_json[0]['name'] == user_1.first_name
assert response_json[0]['email'] == user_1.email
assert 'created_on' in response_json[0]
assert response_json[1]['permissions'] == 'MEMBER'
assert response_json[1]['name'] == user_2.first_name
assert response_json[1]['email'] == user_2.email
assert 'created_on' in response_json[1]
@pytest.mark.django_db
def test_update_group_user(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')
user_3, token_3 = data_fixture.create_user_and_token(email='test3@test.nl')
group_1 = data_fixture.create_group()
data_fixture.create_user_group(group=group_1, user=user_1, permissions='ADMIN')
group_user = data_fixture.create_user_group(group=group_1, user=user_2,
permissions='MEMBER')
response = api_client.patch(
reverse('api:groups:users:item', kwargs={'group_user_id': 99999}),
{'permissions': 'MEMBER'},
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_404_NOT_FOUND
assert response_json['error'] == 'ERROR_GROUP_USER_DOES_NOT_EXIST'
response = api_client.patch(
reverse(
'api:groups:users:item',
kwargs={'group_user_id': group_user.id}
),
{'permissions': 'ADMIN'},
HTTP_AUTHORIZATION=f'JWT {token_3}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_NOT_IN_GROUP'
response = api_client.patch(
reverse(
'api:groups:users:item',
kwargs={'group_user_id': group_user.id}
),
{'permissions': 'ADMIN'},
HTTP_AUTHORIZATION=f'JWT {token_2}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
response = api_client.patch(
reverse(
'api:groups:users:item',
kwargs={'group_user_id': group_user.id}
),
{'permissions': 'NOT_EXISTING'},
format='json',
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
assert response_json['detail']['permissions'][0]['code'] == 'invalid_choice'
response = api_client.patch(
reverse(
'api:groups:users:item',
kwargs={'group_user_id': group_user.id}
),
{'permissions': 'ADMIN'},
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['permissions'] == 'ADMIN'
@pytest.mark.django_db
def test_delete_group_user(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')
user_3, token_3 = data_fixture.create_user_and_token(email='test3@test.nl')
group_1 = data_fixture.create_group()
data_fixture.create_user_group(group=group_1, user=user_1, permissions='ADMIN')
group_user = data_fixture.create_user_group(group=group_1, user=user_2,
permissions='MEMBER')
response = api_client.delete(
reverse('api:groups:users:item', kwargs={'group_user_id': 99999}),
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
response_json = response.json()
assert response.status_code == HTTP_404_NOT_FOUND
assert response_json['error'] == 'ERROR_GROUP_USER_DOES_NOT_EXIST'
response = api_client.delete(
reverse(
'api:groups:users:item',
kwargs={'group_user_id': group_user.id}
),
HTTP_AUTHORIZATION=f'JWT {token_3}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_NOT_IN_GROUP'
response = api_client.delete(
reverse(
'api:groups:users:item',
kwargs={'group_user_id': group_user.id}
),
HTTP_AUTHORIZATION=f'JWT {token_2}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
response = api_client.delete(
reverse(
'api:groups:users:item',
kwargs={'group_user_id': group_user.id}
),
HTTP_AUTHORIZATION=f'JWT {token_1}'
)
assert response.status_code == HTTP_204_NO_CONTENT
assert GroupUser.objects.all().count() == 1

View file

@ -13,12 +13,14 @@ def test_list_groups(api_client, data_fixture):
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)
data_fixture.create_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()
assert len(response_json) == 2
assert response_json[0]['id'] == user_group_1.group.id
assert response_json[0]['order'] == 1
assert response_json[1]['id'] == user_group_2.group.id
@ -50,7 +52,9 @@ def test_create_group(api_client, data_fixture):
@pytest.mark.django_db
def test_update_group(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
user_2, token_2 = data_fixture.create_user_and_token()
group = data_fixture.create_group(user=user, name='Old name')
data_fixture.create_user_group(user=user_2, group=group, permissions='MEMBER')
group_2 = data_fixture.create_group()
url = reverse('api:groups:item', kwargs={'group_id': 99999})
@ -73,6 +77,16 @@ def test_update_group(api_client, data_fixture):
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_USER_NOT_IN_GROUP'
url = reverse('api:groups:item', kwargs={'group_id': group.id})
response = api_client.patch(
url,
{'name': 'New name'},
format='json',
HTTP_AUTHORIZATION=f'JWT {token_2}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
url = reverse('api:groups:item', kwargs={'group_id': group.id})
response = api_client.patch(
url,
@ -93,7 +107,9 @@ def test_update_group(api_client, data_fixture):
@pytest.mark.django_db
def test_delete_group(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
user_2, token_2 = data_fixture.create_user_and_token()
group = data_fixture.create_group(user=user, name='Old name')
data_fixture.create_user_group(user=user_2, group=group, permissions='MEMBER')
group_2 = data_fixture.create_group()
url = reverse('api:groups:item', kwargs={'group_id': 99999})
@ -112,6 +128,14 @@ def test_delete_group(api_client, data_fixture):
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_USER_NOT_IN_GROUP'
url = reverse('api:groups:item', kwargs={'group_id': group.id})
response = api_client.delete(
url,
HTTP_AUTHORIZATION=f'JWT {token_2}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR'
url = reverse('api:groups:item', kwargs={'group_id': group.id})
response = api_client.delete(
url,

View file

@ -6,7 +6,10 @@ from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST
from django.contrib.auth import get_user_model
from django.shortcuts import reverse
from baserow.core.handler import CoreHandler
from baserow.core.models import Group, GroupUser
from baserow.core.user.handler import UserHandler
from baserow.contrib.database.models import Database, Table
User = get_user_model()
@ -19,19 +22,21 @@ def test_create_user(client):
'email': 'test@test.nl',
'password': 'test12'
}, format='json')
response_json = response.json()
assert response.status_code == HTTP_200_OK
user = User.objects.get(email='test@test.nl')
assert user.first_name == 'Test1'
assert user.email == 'test@test.nl'
assert user.password != ''
assert 'password' not in response_json['user']
assert response_json['user']['username'] == 'test@test.nl'
assert response_json['user']['first_name'] == 'Test1'
response_failed = client.post(reverse('api:user:index'), {
'name': 'Test1',
'email': 'test@test.nl',
'password': 'test12'
}, format='json')
assert response_failed.status_code == 400
assert response_failed.json()['error'] == 'ERROR_EMAIL_ALREADY_EXISTS'
@ -40,14 +45,12 @@ def test_create_user(client):
'email': ' teSt@teST.nl ',
'password': 'test12'
}, format='json')
assert response_failed.status_code == 400
assert response_failed.json()['error'] == 'ERROR_EMAIL_ALREADY_EXISTS'
response_failed_2 = client.post(reverse('api:user:index'), {
'email': 'test'
}, format='json')
assert response_failed_2.status_code == 400
long_password = 'x' * 256
@ -72,6 +75,55 @@ def test_create_user(client):
assert response_json['detail']['password'][0]['code'] == 'max_length'
@pytest.mark.django_db
def test_create_user_with_invitation(data_fixture, client):
core_handler = CoreHandler()
invitation = data_fixture.create_group_invitation(email='test0@test.nl')
signer = core_handler.get_group_invitation_signer()
response_failed = client.post(reverse('api:user:index'), {
'name': 'Test1',
'email': 'test@test.nl',
'password': 'test12',
'group_invitation_token': 'INVALID'
}, format='json')
assert response_failed.status_code == 400
assert response_failed.json()['error'] == 'BAD_TOKEN_SIGNATURE'
response_failed = client.post(reverse('api:user:index'), {
'name': 'Test1',
'email': 'test@test.nl',
'password': 'test12',
'group_invitation_token': signer.dumps(99999)
}, format='json')
assert response_failed.status_code == 404
assert response_failed.json()['error'] == 'ERROR_GROUP_INVITATION_DOES_NOT_EXIST'
response_failed = client.post(reverse('api:user:index'), {
'name': 'Test1',
'email': 'test@test.nl',
'password': 'test12',
'group_invitation_token': signer.dumps(invitation.id)
}, format='json')
assert response_failed.status_code == 400
assert response_failed.json()['error'] == 'ERROR_GROUP_INVITATION_EMAIL_MISMATCH'
assert User.objects.all().count() == 1
response_failed = client.post(reverse('api:user:index'), {
'name': 'Test1',
'email': 'test0@test.nl',
'password': 'test12',
'group_invitation_token': signer.dumps(invitation.id)
}, format='json')
assert response_failed.status_code == 200
assert User.objects.all().count() == 2
assert Group.objects.all().count() == 1
assert Group.objects.all().first().id == invitation.group_id
assert GroupUser.objects.all().count() == 2
assert Database.objects.all().count() == 0
assert Table.objects.all().count() == 0
@pytest.mark.django_db
def test_send_reset_password_email(data_fixture, client, mailoutbox):
data_fixture.create_user(email='test@localhost.nl')
@ -259,3 +311,38 @@ def test_change_password(data_fixture, client):
user.refresh_from_db()
assert user.check_password('new')
@pytest.mark.django_db
def test_dashboard(data_fixture, client):
user, token = data_fixture.create_user_and_token(email='test@localhost')
group_1 = data_fixture.create_group(name='Test1')
group_2 = data_fixture.create_group()
invitation_1 = data_fixture.create_group_invitation(
group=group_1,
email='test@localhost'
)
data_fixture.create_group_invitation(
group=group_1,
email='test2@localhost'
)
data_fixture.create_group_invitation(
group=group_2,
email='test3@localhost'
)
response = client.get(
reverse('api:user:dashboard'),
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert len(response_json['group_invitations']) == 1
assert response_json['group_invitations'][0]['id'] == invitation_1.id
assert response_json['group_invitations'][0]['email'] == invitation_1.email
assert response_json['group_invitations'][0]['invited_by'] == (
invitation_1.invited_by.first_name
)
assert response_json['group_invitations'][0]['group'] == 'Test1'
assert response_json['group_invitations'][0]['message'] == invitation_1.message
assert 'created_on' in response_json['group_invitations'][0]

View file

@ -1,13 +1,19 @@
import pytest
from unittest.mock import patch
from itsdangerous.exc import BadSignature
from django.db import connection
from baserow.core.handler import CoreHandler
from baserow.core.models import Group, GroupUser, Application
from baserow.core.models import (
Group, GroupUser, GroupInvitation, Application, GROUP_USER_PERMISSION_ADMIN
)
from baserow.core.exceptions import (
UserNotInGroupError, ApplicationTypeDoesNotExist, GroupDoesNotExist,
ApplicationDoesNotExist
GroupUserDoesNotExist, ApplicationDoesNotExist, UserInvalidGroupPermissionsError,
BaseURLHostnameNotAllowed, GroupInvitationEmailMismatch,
GroupInvitationDoesNotExist, GroupUserAlreadyExists
)
from baserow.contrib.database.models import Database, Table
@ -15,24 +21,21 @@ from baserow.contrib.database.models import Database, Table
@pytest.mark.django_db
def test_get_group(data_fixture):
user_1 = data_fixture.create_user()
user_2 = data_fixture.create_user()
data_fixture.create_user()
group_1 = data_fixture.create_group(user=user_1)
handler = CoreHandler()
with pytest.raises(GroupDoesNotExist):
handler.get_group(user=user_1, group_id=0)
handler.get_group(group_id=0)
with pytest.raises(UserNotInGroupError):
handler.get_group(user=user_2, group_id=group_1.id)
group_1_copy = handler.get_group(user=user_1, group_id=group_1.id)
group_1_copy = handler.get_group(group_id=group_1.id)
assert group_1_copy.id == group_1.id
# If the error is raised we know for sure that the query has resolved.
with pytest.raises(AttributeError):
handler.get_group(
user=user_1, group_id=group_1.id,
group_id=group_1.id,
base_queryset=Group.objects.prefetch_related('UNKNOWN')
)
@ -40,28 +43,88 @@ def test_get_group(data_fixture):
@pytest.mark.django_db
def test_get_group_user(data_fixture):
user_1 = data_fixture.create_user()
user_2 = data_fixture.create_user()
group_1 = data_fixture.create_group(user=user_1)
data_fixture.create_user()
group_1 = data_fixture.create_group()
group_user_1 = data_fixture.create_user_group(user=user_1, group=group_1)
handler = CoreHandler()
with pytest.raises(GroupDoesNotExist):
handler.get_group_user(user=user_1, group_id=0)
with pytest.raises(GroupUserDoesNotExist):
handler.get_group_user(group_user_id=0)
with pytest.raises(UserNotInGroupError):
handler.get_group_user(user=user_2, group_id=group_1.id)
group_user_1_copy = handler.get_group_user(user=user_1, group_id=group_1.id)
assert group_user_1_copy.group_id == group_1.id
group_user = handler.get_group_user(group_user_id=group_user_1.id)
assert group_user.id == group_user_1.id
assert group_user_1.group_id == group_1.id
# If the error is raised we know for sure that the query has resolved.
with pytest.raises(AttributeError):
handler.get_group_user(
user=user_1, group_id=group_1.id,
group_user_id=group_user_1.id,
base_queryset=GroupUser.objects.prefetch_related('UNKNOWN')
)
@pytest.mark.django_db
@patch('baserow.core.signals.group_user_updated.send')
def test_update_group_user(send_mock, data_fixture):
user_1 = data_fixture.create_user()
user_2 = data_fixture.create_user()
user_3 = data_fixture.create_user()
group_1 = data_fixture.create_group()
data_fixture.create_user_group(user=user_1, group=group_1, permissions='ADMIN')
group_user_2 = data_fixture.create_user_group(user=user_2, group=group_1,
permissions='MEMBER')
handler = CoreHandler()
with pytest.raises(UserNotInGroupError):
handler.update_group_user(user=user_3, group_user=group_user_2)
with pytest.raises(UserInvalidGroupPermissionsError):
handler.update_group_user(user=user_2, group_user=group_user_2)
tmp = handler.update_group_user(user=user_1, group_user=group_user_2,
permissions='ADMIN')
send_mock.assert_called_once()
assert send_mock.call_args[1]['group_user'].id == group_user_2.id
assert send_mock.call_args[1]['user'].id == user_1.id
group_user_2.refresh_from_db()
assert tmp.id == group_user_2.id
assert tmp.permissions == 'ADMIN'
assert group_user_2.permissions == 'ADMIN'
@pytest.mark.django_db
@patch('baserow.core.signals.group_user_deleted.send')
def test_delete_group_user(send_mock, data_fixture):
user_1 = data_fixture.create_user()
user_2 = data_fixture.create_user()
user_3 = data_fixture.create_user()
group_1 = data_fixture.create_group()
data_fixture.create_user_group(user=user_1, group=group_1, permissions='ADMIN')
group_user_2 = data_fixture.create_user_group(user=user_2, group=group_1,
permissions='MEMBER')
handler = CoreHandler()
with pytest.raises(UserNotInGroupError):
handler.delete_group_user(user=user_3, group_user=group_user_2)
with pytest.raises(UserInvalidGroupPermissionsError):
handler.delete_group_user(user=user_2, group_user=group_user_2)
group_user_id = group_user_2.id
handler.delete_group_user(user=user_1, group_user=group_user_2)
assert GroupUser.objects.all().count() == 1
send_mock.assert_called_once()
assert send_mock.call_args[1]['group_user_id'] == group_user_id
assert send_mock.call_args[1]['group_user'].group_id == group_user_2.group_id
assert send_mock.call_args[1]['user'].id == user_1.id
@pytest.mark.django_db
@patch('baserow.core.signals.group_created.send')
def test_create_group(send_mock, data_fixture):
@ -81,6 +144,7 @@ def test_create_group(send_mock, data_fixture):
assert user_group.user == user
assert user_group.group == group
assert user_group.order == 1
assert user_group.permissions == GROUP_USER_PERMISSION_ADMIN
handler.create_group(user=user, name='Test group 2')
@ -178,6 +242,357 @@ def test_order_groups(data_fixture):
assert [1, 2, 3] == [ug_2.order, ug_1.order, ug_3.order]
@pytest.mark.django_db
def test_get_group_invitation_by_token(data_fixture):
user = data_fixture.create_user()
group_user = data_fixture.create_user_group(user=user)
invitation = data_fixture.create_group_invitation(
group=group_user.group,
email=user.email
)
handler = CoreHandler()
signer = handler.get_group_invitation_signer()
with pytest.raises(BadSignature):
handler.get_group_invitation_by_token(token='INVALID')
with pytest.raises(GroupInvitationDoesNotExist):
handler.get_group_invitation_by_token(token=signer.dumps(999999))
invitation2 = handler.get_group_invitation_by_token(
token=signer.dumps(invitation.id)
)
assert invitation.id == invitation2.id
assert invitation.invited_by_id == invitation2.invited_by_id
assert invitation.group_id == invitation2.group_id
assert invitation.email == invitation2.email
assert invitation.permissions == invitation2.permissions
assert isinstance(invitation2, GroupInvitation)
with pytest.raises(AttributeError):
handler.get_group_invitation_by_token(
token=signer.dumps(invitation.id),
base_queryset=GroupInvitation.objects.prefetch_related('UNKNOWN')
)
@pytest.mark.django_db
def test_get_group_invitation(data_fixture):
user = data_fixture.create_user()
user_2 = data_fixture.create_user()
user_3 = data_fixture.create_user()
group_user = data_fixture.create_user_group(user=user)
data_fixture.create_user_group(
user=user_2,
group=group_user.group,
permissions='MEMBER'
)
invitation = data_fixture.create_group_invitation(
group=group_user.group,
email=user.email
)
handler = CoreHandler()
with pytest.raises(GroupInvitationDoesNotExist):
handler.get_group_invitation(user=user, group_invitation_id=999999)
with pytest.raises(UserNotInGroupError):
handler.get_group_invitation(user=user_3, group_invitation_id=invitation.id)
with pytest.raises(UserInvalidGroupPermissionsError):
handler.get_group_invitation(user=user_2, group_invitation_id=invitation.id)
invitation2 = handler.get_group_invitation(
user=user,
group_invitation_id=invitation.id
)
assert invitation.id == invitation2.id
assert invitation.invited_by_id == invitation2.invited_by_id
assert invitation.group_id == invitation2.group_id
assert invitation.email == invitation2.email
assert invitation.permissions == invitation2.permissions
assert isinstance(invitation2, GroupInvitation)
with pytest.raises(AttributeError):
handler.get_field(
invitation_id=invitation.id,
base_queryset=GroupInvitation.objects.prefetch_related('UNKNOWN')
)
@pytest.mark.django_db
def test_send_group_invitation_email(data_fixture, mailoutbox):
group_invitation = data_fixture.create_group_invitation()
handler = CoreHandler()
with pytest.raises(BaseURLHostnameNotAllowed):
handler.send_group_invitation_email(
invitation=group_invitation,
base_url='http://test.nl/group-invite'
)
signer = handler.get_group_invitation_signer()
handler.send_group_invitation_email(
invitation=group_invitation,
base_url='http://localhost:3000/group-invite'
)
assert len(mailoutbox) == 1
email = mailoutbox[0]
assert email.subject == f'{group_invitation.invited_by.first_name} invited you ' \
f'to {group_invitation.group.name} - Baserow'
assert email.from_email == 'no-reply@localhost'
assert group_invitation.email in email.to
html_body = email.alternatives[0][0]
search_url = 'http://localhost:3000/group-invite/'
start_url_index = html_body.index(search_url)
assert start_url_index != -1
end_url_index = html_body.index('"', start_url_index)
token = html_body[start_url_index + len(search_url):end_url_index]
invitation_id = signer.loads(token)
assert invitation_id == group_invitation.id
@pytest.mark.django_db
@patch('baserow.core.handler.CoreHandler.send_group_invitation_email')
def test_create_group_invitation(mock_send_email, data_fixture):
user_group = data_fixture.create_user_group()
user = user_group.user
group = user_group.group
user_2 = data_fixture.create_user()
user_group_3 = data_fixture.create_user_group(group=group, permissions='MEMBER')
user_3 = user_group_3.user
handler = CoreHandler()
with pytest.raises(UserNotInGroupError):
handler.create_group_invitation(
user=user_2,
group=group,
email='test@test.nl',
permissions='ADMIN',
message='Test',
base_url='http://localhost:3000/invite'
)
with pytest.raises(UserInvalidGroupPermissionsError):
handler.create_group_invitation(
user=user_3,
group=group,
email='test@test.nl',
permissions='ADMIN',
message='Test',
base_url='http://localhost:3000/invite'
)
with pytest.raises(GroupUserAlreadyExists):
handler.create_group_invitation(
user=user,
group=group,
email=user_3.email,
permissions='ADMIN',
message='Test',
base_url='http://localhost:3000/invite'
)
with pytest.raises(ValueError):
handler.create_group_invitation(
user=user,
group=group,
email='test@test.nl',
permissions='NOT_EXISTING',
message='Test',
base_url='http://localhost:3000/invite'
)
invitation = handler.create_group_invitation(
user=user,
group=group,
email='test@test.nl',
permissions='ADMIN',
message='Test',
base_url='http://localhost:3000/invite'
)
assert invitation.invited_by_id == user.id
assert invitation.group_id == group.id
assert invitation.email == 'test@test.nl'
assert invitation.permissions == 'ADMIN'
assert invitation.message == 'Test'
assert GroupInvitation.objects.all().count() == 1
mock_send_email.assert_called_once()
assert mock_send_email.call_args[0][0].id == invitation.id
assert mock_send_email.call_args[0][1] == 'http://localhost:3000/invite'
# Because there already is an invitation for this email and group, it must be
# updated instead of having duplicates.
invitation = handler.create_group_invitation(
user=user,
group=group,
email='test@test.nl',
permissions='MEMBER',
message='New message',
base_url='http://localhost:3000/invite'
)
assert invitation.invited_by_id == user.id
assert invitation.group_id == group.id
assert invitation.email == 'test@test.nl'
assert invitation.permissions == 'MEMBER'
assert invitation.message == 'New message'
assert GroupInvitation.objects.all().count() == 1
invitation = handler.create_group_invitation(
user=user,
group=group,
email='test2@test.nl',
permissions='ADMIN',
message='',
base_url='http://localhost:3000/invite'
)
assert invitation.invited_by_id == user.id
assert invitation.group_id == group.id
assert invitation.email == 'test2@test.nl'
assert invitation.permissions == 'ADMIN'
assert invitation.message == ''
assert GroupInvitation.objects.all().count() == 2
@pytest.mark.django_db
def test_update_group_invitation(data_fixture):
group_invitation = data_fixture.create_group_invitation()
user = group_invitation.invited_by
user_2 = data_fixture.create_user()
handler = CoreHandler()
with pytest.raises(UserNotInGroupError):
handler.update_group_invitation(
user=user_2,
invitation=group_invitation,
permissions='ADMIN'
)
with pytest.raises(ValueError):
handler.update_group_invitation(
user=user,
invitation=group_invitation,
permissions='NOT_EXISTING'
)
invitation = handler.update_group_invitation(
user=user,
invitation=group_invitation,
permissions='MEMBER'
)
assert invitation.permissions == 'MEMBER'
invitation = GroupInvitation.objects.all().first()
assert invitation.permissions == 'MEMBER'
@pytest.mark.django_db
def test_delete_group_invitation(data_fixture):
group_invitation = data_fixture.create_group_invitation()
user = group_invitation.invited_by
user_2 = data_fixture.create_user()
handler = CoreHandler()
with pytest.raises(UserNotInGroupError):
handler.delete_group_invitation(
user=user_2,
invitation=group_invitation,
)
handler.delete_group_invitation(
user=user,
invitation=group_invitation,
)
assert GroupInvitation.objects.all().count() == 0
@pytest.mark.django_db
def test_reject_group_invitation(data_fixture):
group_invitation = data_fixture.create_group_invitation(email='test@test.nl')
user_1 = data_fixture.create_user(email='test@test.nl')
user_2 = data_fixture.create_user(email='test2@test.nl')
handler = CoreHandler()
with pytest.raises(GroupInvitationEmailMismatch):
handler.reject_group_invitation(user=user_2, invitation=group_invitation)
assert GroupInvitation.objects.all().count() == 1
handler.reject_group_invitation(user=user_1, invitation=group_invitation)
assert GroupInvitation.objects.all().count() == 0
assert GroupUser.objects.all().count() == 1
@pytest.mark.django_db
def test_accept_group_invitation(data_fixture):
group = data_fixture.create_group()
group_2 = data_fixture.create_group()
group_invitation = data_fixture.create_group_invitation(
email='test@test.nl',
permissions='MEMBER',
group=group
)
user_1 = data_fixture.create_user(email='test@test.nl')
user_2 = data_fixture.create_user(email='test2@test.nl')
handler = CoreHandler()
with pytest.raises(GroupInvitationEmailMismatch):
handler.accept_group_invitation(user=user_2, invitation=group_invitation)
assert GroupInvitation.objects.all().count() == 1
group_user = handler.accept_group_invitation(
user=user_1,
invitation=group_invitation
)
assert group_user.group_id == group.id
assert group_user.permissions == 'MEMBER'
assert GroupInvitation.objects.all().count() == 0
assert GroupUser.objects.all().count() == 1
group_invitation = data_fixture.create_group_invitation(
email='test@test.nl',
permissions='ADMIN',
group=group
)
group_user = handler.accept_group_invitation(
user=user_1,
invitation=group_invitation
)
assert group_user.group_id == group.id
assert group_user.permissions == 'ADMIN'
assert GroupInvitation.objects.all().count() == 0
assert GroupUser.objects.all().count() == 1
group_invitation = data_fixture.create_group_invitation(
email='test@test.nl',
permissions='MEMBER',
group=group_2
)
group_user = handler.accept_group_invitation(
user=user_1,
invitation=group_invitation
)
assert group_user.group_id == group_2.id
assert group_user.permissions == 'MEMBER'
assert GroupInvitation.objects.all().count() == 0
assert GroupUser.objects.all().count() == 2
@pytest.mark.django_db
def test_get_application(data_fixture):
user_1 = data_fixture.create_user()

View file

@ -4,6 +4,9 @@ from freezegun import freeze_time
from datetime import datetime
from baserow.core.models import GroupUser, Group
from baserow.core.exceptions import (
UserNotInGroupError, UserInvalidGroupPermissionsError
)
from baserow.contrib.database.models import Database
@ -40,11 +43,38 @@ def test_group_user_get_next_order(data_fixture):
@pytest.mark.django_db
def test_group_has_user(data_fixture):
user = data_fixture.create_user()
user_group = data_fixture.create_user_group()
user_group = data_fixture.create_user_group(permissions='ADMIN')
user_group_2 = data_fixture.create_user_group(permissions='MEMBER')
assert user_group.group.has_user(user_group.user)
assert not user_group.group.has_user(user)
assert not user_group.group.has_user(user, 'ADMIN')
assert not user_group.group.has_user(user, ['ADMIN', 'MEMBER'])
assert not user_group.group.has_user(user_group.user, 'MEMBER')
assert user_group.group.has_user(user_group.user, 'ADMIN')
assert user_group.group.has_user(
user_group.user, ['ADMIN', 'MEMBER']
)
user_group.group.has_user(user_group.user, raise_error=True)
with pytest.raises(UserNotInGroupError):
user_group.group.has_user(user, raise_error=True)
with pytest.raises(UserNotInGroupError):
user_group.group.has_user(user, 'ADMIN', raise_error=True)
with pytest.raises(UserInvalidGroupPermissionsError):
user_group_2.group.has_user(
user_group_2.user,
'ADMIN',
raise_error=True
)
user_group.group.has_user(user_group.user, 'ADMIN', raise_error=True)
user_group_2.group.has_user(user_group_2.user, 'MEMBER', raise_error=True)
@pytest.mark.django_db
def test_application_content_type_init(data_fixture):

View file

@ -5,14 +5,19 @@ from freezegun import freeze_time
from itsdangerous.exc import SignatureExpired, BadSignature
from baserow.core.models import Group
from baserow.core.models import Group, GroupUser
from baserow.core.registries import plugin_registry
from baserow.contrib.database.models import (
Database, Table, GridView, TextField, LongTextField, BooleanField, DateField
)
from baserow.contrib.database.views.models import GridViewFieldOptions
from baserow.core.exceptions import (
BaseURLHostnameNotAllowed, GroupInvitationEmailMismatch,
GroupInvitationDoesNotExist
)
from baserow.core.handler import CoreHandler
from baserow.core.user.exceptions import (
UserAlreadyExist, UserNotFound, InvalidPassword, BaseURLHostnameNotAllowed
UserAlreadyExist, UserNotFound, InvalidPassword
)
from baserow.core.user.handler import UserHandler
@ -80,12 +85,53 @@ def test_create_user():
assert model_2_results[1].order == Decimal('2.00000000000000000000')
assert model_2_results[2].order == Decimal('3.00000000000000000000')
plugin_mock.user_created.assert_called_with(user, group)
plugin_mock.user_created.assert_called_with(user, group, None)
with pytest.raises(UserAlreadyExist):
user_handler.create_user('Test1', 'test@test.nl', 'password')
@pytest.mark.django_db
def test_create_user_with_invitation(data_fixture):
plugin_mock = MagicMock()
plugin_registry.registry['mock'] = plugin_mock
user_handler = UserHandler()
core_handler = CoreHandler()
invitation = data_fixture.create_group_invitation(email='test0@test.nl')
signer = core_handler.get_group_invitation_signer()
with pytest.raises(BadSignature):
user_handler.create_user('Test1', 'test0@test.nl', 'password', 'INVALID')
with pytest.raises(GroupInvitationDoesNotExist):
user_handler.create_user('Test1', 'test0@test.nl', 'password',
signer.dumps(99999))
with pytest.raises(GroupInvitationEmailMismatch):
user_handler.create_user('Test1', 'test1@test.nl', 'password',
signer.dumps(invitation.id))
user = user_handler.create_user('Test1', 'test0@test.nl', 'password',
signer.dumps(invitation.id))
assert Group.objects.all().count() == 1
assert Group.objects.all().first().id == invitation.group_id
assert GroupUser.objects.all().count() == 2
plugin_mock.user_created.assert_called_once()
args = plugin_mock.user_created.call_args
assert args[0][0] == user
assert args[0][1].id == invitation.group_id
assert args[0][2].email == invitation.email
assert args[0][2].group_id == invitation.group_id
# We do not expect any initial data to have been created.
assert Database.objects.all().count() == 0
assert Table.objects.all().count() == 0
@pytest.mark.django_db
def test_send_reset_password_email(data_fixture, mailoutbox):
user = data_fixture.create_user(email='test@localhost')
@ -100,7 +146,7 @@ def test_send_reset_password_email(data_fixture, mailoutbox):
assert len(mailoutbox) == 1
email = mailoutbox[0]
assert email.subject == 'Reset password'
assert email.subject == 'Reset password - Baserow'
assert email.from_email == 'no-reply@localhost'
assert 'test@localhost' in email.to

View file

@ -50,6 +50,44 @@ def test_group_deleted(mock_broadcast_to_users, data_fixture):
assert args[0][1]['group_id'] == group_id
@pytest.mark.django_db(transaction=True)
@patch('baserow.ws.signals.broadcast_to_users')
def test_group_user_updated(mock_broadcast_to_users, 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)
data_fixture.create_user_group(user=user_2, group=group)
CoreHandler().update_group_user(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'
assert args[0][1]['group_id'] == group.id
@pytest.mark.django_db(transaction=True)
@patch('baserow.ws.signals.broadcast_to_users')
def test_group_user_deleted(mock_broadcast_to_users, 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)
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]['group_id'] == group.id
@pytest.mark.django_db(transaction=True)
@patch('baserow.ws.signals.broadcast_to_group')
def test_application_created(mock_broadcast_to_group, data_fixture):

View file

@ -1,4 +1,4 @@
from baserow.core.models import Group, GroupUser
from baserow.core.models import Group, GroupUser, GroupInvitation
class GroupFixtures:
@ -29,4 +29,25 @@ class GroupFixtures:
if 'order' not in kwargs:
kwargs['order'] = 0
if 'permissions' not in kwargs:
kwargs['permissions'] = 'ADMIN'
return GroupUser.objects.create(**kwargs)
def create_group_invitation(self, **kwargs):
if 'invited_by' not in kwargs:
kwargs['invited_by'] = self.create_user()
if 'group' not in kwargs:
kwargs['group'] = self.create_group(user=kwargs['invited_by'])
if 'email' not in kwargs:
kwargs['email'] = self.fake.email()
if 'permissions' not in kwargs:
kwargs['permissions'] = 'ADMIN'
if 'message' not in kwargs:
kwargs['message'] = self.fake.name()
return GroupInvitation.objects.create(**kwargs)

View file

@ -14,6 +14,7 @@
field type.
* Fixed bug where the row in the RowEditModel was not entirely reactive and wouldn't be
updated when the grid view was refreshed.
* Made it possible to invite other users to a group.
## Released (2021-01-06)

View file

@ -58,3 +58,6 @@
@import 'select_options';
@import 'select_options_listing';
@import 'color_select';
@import 'group_member';
@import 'separator';
@import 'quote';

View file

@ -18,21 +18,23 @@
padding: 20px 30px 0 30px;
}
.dashboard__group-title {
font-size: 18px;
.dashboard__group-head {
display: flex;
justify-content: space-between;
border-bottom: 1px solid $color-neutral-200;
font-weight: 700;
padding-bottom: 16px;
margin-bottom: 20px;
}
.dashboard__group-title-link {
.dashboard__group-title {
@extend %ellipsis;
display: block;
width: 100%;
font-size: 18px;
font-weight: 700;
max-width: 100%;
position: relative;
color: $color-primary-900;
min-width: 0;
&:hover {
text-decoration: none;
@ -50,6 +52,15 @@
}
}
.dashboard__group-title-options {
color: $color-primary-900;
}
.dashboard__group-link {
flex: 0 0;
margin-left: 16px;
}
.dashboard__group-title-icon {
margin-left: 10px;
font-size: 14px;

View file

@ -14,7 +14,7 @@
padding: 0 32px 0 12px;
color: $color-primary-900;
@include fixed-height(32px, 13px);
@include fixed-height(36px, 13px);
&:hover {
text-decoration: none;
@ -24,6 +24,11 @@
.dropdown--error & {
border-color: $color-error-500;
}
.dropdown--tiny & {
height: 32px;
line-height: 32px;
}
}
.dropdown__selected-icon {

View file

@ -0,0 +1,93 @@
.group-member {
display: flex;
align-items: center;
margin: 12px 0;
background-color: $white;
box-shadow: 0 0 0 6px $white;
transition: 1s linear;
transition-property: box-shadow, background-color;
&.group-member--highlight {
transition: none;
background-color: $color-success-100;
box-shadow: 0 0 0 6px $color-success-100;
border-radius: 6px;
}
}
.group-member__initials {
flex: 0 0 36px;
margin-right: 16px;
border-radius: 100%;
background-color: $color-primary-500;
color: $white;
font-weight: 700;
@include center-text(36px, 13px);
}
.group-member__content {
min-width: 0;
width: 100%;
}
.group-member__name {
@extend %ellipsis;
font-size: 13px;
font-weight: 600;
color: $color-primary-900;
margin-bottom: 4px;
}
.group-member__description {
@extend %ellipsis;
font-size: 12px;
color: $color-neutral-600;
}
.group-member__permissions {
flex: 0 0 176px;
margin-right: 16px;
}
.group-member__actions {
flex: 0 0;
display: flex;
}
.group-member__action {
position: relative;
color: $color-neutral-400;
border-radius: 3px;
@include center-text(24px, 13px);
&:not(:last-child) {
margin-right: 8px;
}
&:hover {
color: $color-primary-900;
background-color: $color-neutral-100;
}
&.group-member__action--loading {
cursor: inherit;
color: $color-neutral-100;
background-color: $color-neutral-100;
&::after {
content: '';
margin: -7px auto auto -7px;
@include loading(14px);
@include absolute(50%, auto, auto, 50%);
}
}
}
.dropdown-member__delete-link {
color: $color-neutral-500;
}

View file

@ -60,5 +60,5 @@
color: $white;
font-weight: 700;
@include center-text(32px, 18px);
@include center-text(32px, 13px);
}

View file

@ -12,7 +12,7 @@
color: $white;
font-weight: 700;
@include center-text(32px, 18px);
@include center-text(32px, 13px);
}
.modal-sidebar__head-name {

View file

@ -0,0 +1,6 @@
.quote {
border-left: solid 4px $color-neutral-200;
padding: 4px 0 4px 10px;
margin: 10px 0;
font-style: italic;
}

View file

@ -48,8 +48,6 @@
}
.select__item {
@extend %select__item-size;
position: relative;
margin: 0 8px 4px 8px;
padding: 0 32px 0 10px;
@ -86,7 +84,7 @@
@include absolute(0, 0, auto, auto);
}
&:hover::after {
&:not(.select__item--no-options):hover::after {
display: none;
}
}
@ -97,9 +95,6 @@
}
.select__item-link {
@extend %ellipsis;
@extend %select__item-size;
display: block;
color: $color-primary-900;
@ -116,10 +111,23 @@
}
}
.select__item-name {
@extend %ellipsis;
@extend %select__item-size;
}
.select__item-icon {
margin-right: 6px;
}
.select__item-description {
font-size: 11px;
margin-right: -32px;
line-height: 140%;
color: $color-neutral-600;
padding-bottom: 6px;
}
.select__item-options {
@extend %select__item-size;

View file

@ -0,0 +1,4 @@
.separator {
margin: 30px 0;
border-bottom: solid 1px $color-neutral-200;
}

View file

@ -13,12 +13,17 @@
@click="select(value, disabled)"
@mousemove="hover(value, disabled)"
>
<i
v-if="icon"
class="select__item-icon fas fa-fw"
:class="'fa-' + icon"
></i>
{{ name }}
<div class="select__item-name">
<i
v-if="icon"
class="select__item-icon fas fa-fw"
:class="'fa-' + icon"
></i>
{{ name }}
</div>
<div v-if="description !== null" class="select__item-description">
{{ description }}
</div>
</a>
</li>
</template>

View file

@ -1,37 +1,36 @@
<template>
<div class="dashboard__group">
<h2 class="dashboard__group-title">
<a
<div class="dashboard__group-head">
<div
ref="contextLink"
class="dashboard__group-title-link"
class="dashboard__group-title"
:class="{ 'dashboard__group-title-link--loading': group._.loading }"
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 0)"
>
<Editable
ref="rename"
:value="group.name"
@change="renameGroup(group, $event)"
></Editable>
<i class="dashboard__group-title-icon fas fa-caret-down"></i>
</a>
<Context ref="context">
<ul class="context__menu">
<li>
<a @click="enableRename()">
<i class="context__menu-icon fas fa-fw fa-pen"></i>
Rename group
</a>
</li>
<li>
<a @click="deleteGroup(group)">
<i class="context__menu-icon fas fa-fw fa-trash"></i>
Delete group
</a>
</li>
</ul>
<DeleteGroupModal ref="deleteGroupModal" :group="group" />
</Context>
</h2>
<a
v-if="group.permissions === 'ADMIN'"
class="dashboard__group-title-options"
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 0)"
>
<i class="dashboard__group-title-icon fas fa-caret-down"></i>
</a>
</div>
<GroupContext
ref="context"
:group="group"
@rename="enableRename()"
></GroupContext>
<a
v-if="group.permissions === 'ADMIN'"
class="dashboard__group-link"
@click="$refs.context.showGroupMembersModal()"
>Members</a
>
</div>
<ul class="dashboard__group-items">
<li
v-for="application in getAllOfGroup(group)"
@ -80,13 +79,13 @@
import { mapGetters } from 'vuex'
import CreateApplicationContext from '@baserow/modules/core/components/application/CreateApplicationContext'
import DeleteGroupModal from '@baserow/modules/core/components/group/DeleteGroupModal'
import GroupContext from '@baserow/modules/core/components/group/GroupContext'
import editGroup from '@baserow/modules/core/mixins/editGroup'
export default {
components: {
CreateApplicationContext,
DeleteGroupModal,
GroupContext,
},
mixins: [editGroup],
props: {

View file

@ -0,0 +1,52 @@
<template>
<Context ref="context">
<ul class="context__menu">
<li>
<a @click="$emit('rename')">
<i class="context__menu-icon fas fa-fw fa-pen"></i>
Rename group
</a>
</li>
<li>
<a @click="$refs.groupMembersModal.show()">
<i class="context__menu-icon fas fa-fw fa-users"></i>
Members
</a>
</li>
<li>
<a @click="$refs.deleteGroupModal.show()">
<i class="context__menu-icon fas fa-fw fa-trash"></i>
Delete group
</a>
</li>
</ul>
<GroupMembersModal
ref="groupMembersModal"
:group="group"
></GroupMembersModal>
<DeleteGroupModal ref="deleteGroupModal" :group="group" />
</Context>
</template>
<script>
import DeleteGroupModal from '@baserow/modules/core/components/group/DeleteGroupModal'
import GroupMembersModal from '@baserow/modules/core/components/group/GroupMembersModal'
import context from '@baserow/modules/core/mixins/context'
export default {
name: 'GroupContext',
components: { DeleteGroupModal, GroupMembersModal },
mixins: [context],
props: {
group: {
type: Object,
required: true,
},
},
methods: {
showGroupMembersModal() {
this.$refs.groupMembersModal.show()
},
},
}
</script>

View file

@ -0,0 +1,98 @@
<template>
<div
class="alert alert--simple alert-primary alert--has-icon dashboard__alert"
>
<div class="alert__icon">
<i class="fas fa-exclamation"></i>
</div>
<div class="alert__title">Invitation</div>
<p class="alert__content">
<strong>{{ invitation.invited_by }}</strong> has invited you to join
<strong>{{ invitation.group }}</strong
>.
</p>
<div v-if="invitation.message !== ''" class="quote">
"{{ invitation.message }}"
</div>
<a
class="button button--error dashboard__alert-button"
:class="{ 'button--loading': rejectLoading }"
:disabled="rejectLoading || acceptLoading"
@click="!rejectLoading && !acceptLoading && reject(invitation)"
>Reject</a
>
<a
class="button button--success dashboard__alert-button"
:class="{ 'button--loading': acceptLoading }"
:disabled="rejectLoading || acceptLoading"
@click="!rejectLoading && !acceptLoading && accept(invitation)"
>Accept</a
>
</div>
</template>
<script>
import GroupService from '@baserow/modules/core/services/group'
import ApplicationService from '@baserow/modules/core/services/application'
import { notifyIf } from '@baserow/modules/core/utils/error'
export default {
name: 'GroupInvitation',
props: {
invitation: {
type: Object,
required: true,
},
},
data() {
return {
rejectLoading: false,
acceptLoading: false,
}
},
methods: {
async reject(invitation) {
this.rejectLoading = true
try {
await GroupService(this.$client).rejectInvitation(invitation.id)
this.$emit('remove', invitation)
} catch (error) {
this.rejectLoading = false
notifyIf(error, 'group')
}
},
/**
* Accepts the invitation to join the group and populates the stores with the new
* group and applications.
*/
async accept(invitation) {
this.acceptLoading = true
try {
const { data: group } = await GroupService(
this.$client
).acceptInvitation(invitation.id)
// After the invitation is accepted and group is received we can immediately
// fetch the applications that belong to the group.
const { data: applications } = await ApplicationService(
this.$client
).fetchAll(group.id)
// The accept endpoint returns a group user object that we can add to the
// store. Also the applications that we just fetched can be added to the
// store.
this.$store.dispatch('group/forceCreate', group)
applications.forEach((application) => {
this.$store.dispatch('application/forceCreate', application)
})
this.$emit('remove', invitation)
} catch (error) {
this.acceptLoading = false
notifyIf(error, 'group')
}
},
},
}
</script>

View file

@ -0,0 +1,82 @@
<template>
<form @submit.prevent="submit">
<h3>Invite by email</h3>
<div class="row">
<div class="col col-7">
<div class="control">
<div class="control__elements">
<input
ref="email"
v-model="values.email"
:class="{ 'input--error': $v.values.email.$error }"
type="text"
class="input"
@blur="$v.values.email.$touch()"
/>
<div v-if="$v.values.email.$error" class="error">
Please enter a valid e-mail address.
</div>
</div>
</div>
</div>
<div class="col col-5">
<div class="control">
<div class="control__elements">
<Dropdown v-model="values.permissions" :show-search="false">
<DropdownItem
name="Admin"
value="ADMIN"
description="Can fully configure and edit groups and applications."
></DropdownItem>
<DropdownItem
name="Member"
value="MEMBER"
description="Can fully configure and edit applications."
></DropdownItem>
</Dropdown>
</div>
</div>
</div>
<div class="col col-12">
<div class="control">
<div class="control__elements">
<input
ref="message"
v-model="values.message"
type="text"
class="input"
placeholder="Optional message"
/>
</div>
</div>
</div>
<slot></slot>
</div>
</form>
</template>
<script>
import { required, email } from 'vuelidate/lib/validators'
import form from '@baserow/modules/core/mixins/form'
export default {
name: 'GroupInviteForm',
mixins: [form],
data() {
return {
loading: false,
values: {
email: '',
permissions: 'MEMBER',
message: '',
},
}
},
validations: {
values: {
email: { required, email },
},
},
}
</script>

View file

@ -0,0 +1,94 @@
<template>
<div
ref="member"
class="group-member"
:class="{ 'group-member--highlight': highlighted }"
>
<div class="group-member__initials">{{ name | nameAbbreviation }}</div>
<div class="group-member__content">
<div class="group-member__name">
{{ name }}
</div>
<div class="group-member__description">
{{ description }}
</div>
</div>
<div class="group-member__permissions">
<Dropdown
v-if="!disabled"
:value="permissions"
:show-search="false"
@input="$emit('updated', { permissions: $event })"
>
<DropdownItem
name="Admin"
value="ADMIN"
description="Can fully configure and edit groups and applications."
></DropdownItem>
<DropdownItem
name="Member"
value="MEMBER"
description="Can fully configure and edit applications."
></DropdownItem>
</Dropdown>
</div>
<div class="group-member__actions">
<a
v-if="!disabled"
class="group-member__action"
:class="{ 'group-member__action--loading': loading }"
@click="$emit('removed')"
>
<i class="fa fa-trash"></i>
</a>
</div>
</div>
</template>
<script>
export default {
name: 'GroupMember',
props: {
id: {
type: Number,
required: true,
},
name: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
permissions: {
type: String,
required: true,
},
loading: {
type: Boolean,
required: false,
default: false,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
highlighted: false,
}
},
methods: {
highlight() {
this.$refs.member.scrollIntoView({ behavior: 'smooth' })
this.highlighted = true
setTimeout(() => {
this.highlighted = false
}, 2000)
},
},
}
</script>

View file

@ -0,0 +1,243 @@
<template>
<Modal @show="initialize">
<h2 class="box__title">{{ group.name }} members</h2>
<Error :error="error"></Error>
<GroupInviteForm ref="inviteForm" @submitted="inviteSubmitted">
<div class="col col-12 align-right">
<button
:class="{ 'button--loading': inviteLoading }"
class="button"
:disabled="inviteLoading"
>
Send invite
</button>
</div>
</GroupInviteForm>
<div v-if="loading" class="loading"></div>
<div v-else-if="!loading">
<div
v-if="users.length > 0 || invitations.length > 0"
class="separator"
></div>
<GroupMember
v-for="user in users"
:id="user.id"
:key="'user-' + user.id"
:name="user.name"
:description="getUserDescription(user)"
:permissions="user.permissions"
:loading="user._.loading"
:disabled="user.email === username"
@updated="updateUser(user, $event)"
@removed="removeUser(user)"
></GroupMember>
<GroupMember
v-for="invitation in invitations"
:id="invitation.id"
:ref="'invitation-' + invitation.id"
:key="'invitation-' + invitation.id"
:name="invitation.email"
:description="getInvitationDescription(invitation)"
:permissions="invitation.permissions"
:loading="invitation._.loading"
@updated="updateInvitation(invitation, $event)"
@removed="removeInvitation(invitation)"
></GroupMember>
</div>
</Modal>
</template>
<script>
import { mapGetters } from 'vuex'
import moment from 'moment'
import modal from '@baserow/modules/core/mixins/modal'
import error from '@baserow/modules/core/mixins/error'
import { notifyIf } from '@baserow/modules/core/utils/error'
import { ResponseErrorMessage } from '@baserow/modules/core/plugins/clientHandler'
import GroupService from '@baserow/modules/core/services/group'
import GroupInviteForm from '@baserow/modules/core/components/group/GroupInviteForm'
import GroupMember from '@baserow/modules/core/components/group/GroupMember'
function populateUser(user) {
user._ = { loading: false }
return user
}
function populateInvitation(invitation) {
invitation._ = { loading: false }
return invitation
}
export default {
name: 'CreateGroupModal',
components: { GroupMember, GroupInviteForm },
mixins: [modal, error],
props: {
group: {
type: Object,
required: true,
},
},
data() {
return {
users: [],
invitations: [],
loading: false,
inviteLoading: false,
}
},
computed: {
...mapGetters({
username: 'auth/getUsername',
}),
},
methods: {
/**
* The initialize method is called when the modal opens. It will fetch all the
* users and invitations of the group.
*/
async initialize() {
this.hideError()
this.loading = true
this.users = []
this.invitations = []
try {
const users = await GroupService(this.$client).fetchAllUsers(
this.group.id
)
const invitations = await GroupService(
this.$client
).fetchAllInvitations(this.group.id)
this.users = users.data.map(populateUser)
this.invitations = invitations.data.map(populateInvitation)
} catch (error) {
this.handleError(error, 'group')
}
this.loading = false
},
/**
* Called when the group invitation form is submitted. It will send a request to
* the backend which will create a new group invitations.
*/
async inviteSubmitted(values) {
this.inviteLoading = true
try {
// The public accept url is the page where the user can publicly navigate too,
// to accept the group invitation.
const acceptUrl = `${this.$env.PUBLIC_WEB_FRONTEND_URL}/group-invitation`
const { data } = await GroupService(this.$client).sendInvitation(
this.group.id,
acceptUrl,
values
)
this.$refs.inviteForm.reset()
const invitation = populateInvitation(data)
// It could be that the an invitation for the email address already exists, in
// that case we want to update the invitation instead of adding it to the list.
const index = this.invitations.findIndex((i) => i.id === data.id)
if (index !== -1) {
this.invitations[index] = invitation
} else {
this.invitations.push(invitation)
}
this.$nextTick(() => {
this.highlightInvitation(invitation)
})
} catch (error) {
this.handleError(error, 'group', {
ERROR_GROUP_USER_ALREADY_EXISTS: new ResponseErrorMessage(
'User is already in the group.',
'It is not possible to send an invitation when the user is ' +
'already a member of the group.'
),
})
}
this.inviteLoading = false
},
highlightInvitation(invitation) {
const name = 'invitation-' + invitation.id
if (!Object.prototype.hasOwnProperty.call(this.$refs, name)) {
return
}
this.$refs[name][0].highlight()
},
getUserDescription(user) {
return `${user.email} - joined ${moment(user.created_on).fromNow()}`
},
async updateUser(user, { permissions }) {
if (user.permissions === permissions) {
return
}
this.hideError()
const oldPermissions = user.permissions
user.permissions = permissions
try {
await GroupService(this.$client).updateUser(user.id, { permissions })
} catch (error) {
user.permissions = oldPermissions
notifyIf(error, 'group')
}
},
async removeUser(user) {
this.hideError()
user._.loading = true
try {
await GroupService(this.$client).deleteUser(user.id)
const index = this.users.findIndex((u) => u.id === user.id)
this.users.splice(index, 1)
} catch (error) {
user._.loading = false
notifyIf(error, 'group')
}
},
getInvitationDescription(invitation) {
return `invited ${moment(invitation.created_on).fromNow()}`
},
async updateInvitation(invitation, { permissions }) {
if (invitation.invitation === permissions) {
return
}
this.hideError()
const oldPermissions = invitation.permissions
invitation.permissions = permissions
try {
await GroupService(this.$client).updateInvitation(invitation.id, {
permissions,
})
} catch (error) {
invitation.permissions = oldPermissions
notifyIf(error, 'group')
}
},
async removeInvitation(invitation) {
this.hideError()
invitation._.loading = true
try {
await GroupService(this.$client).deleteInvitation(invitation.id)
const index = this.invitations.findIndex((i) => i.id === invitation.id)
this.invitations.splice(index, 1)
} catch (error) {
invitation._.loading = false
notifyIf(error, 'group')
}
},
},
}
</script>

View file

@ -4,49 +4,41 @@
:class="{
active: group._.selected,
'select__item--loading': group._.loading,
'select__item--no-options': group.permissions !== 'ADMIN',
}"
>
<a class="select__item-link" @click="selectGroup(group)">
<Editable
ref="rename"
:value="group.name"
@change="renameGroup(group, $event)"
></Editable>
<div class="select__item-name">
<Editable
ref="rename"
:value="group.name"
@change="renameGroup(group, $event)"
></Editable>
</div>
</a>
<a
v-if="group.permissions === 'ADMIN'"
ref="contextLink"
class="select__item-options"
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'right', 0)"
>
<i class="fas fa-ellipsis-v"></i>
</a>
<Context ref="context">
<ul class="context__menu">
<li>
<a @click="enableRename()">
<i class="context__menu-icon fas fa-fw fa-pen"></i>
Rename group
</a>
</li>
<li>
<a @click="deleteGroup(group)">
<i class="context__menu-icon fas fa-fw fa-trash"></i>
Delete group
</a>
</li>
</ul>
<DeleteGroupModal ref="deleteGroupModal" :group="group" />
</Context>
<GroupContext
ref="context"
:group="group"
@rename="enableRename()"
></GroupContext>
</li>
</template>
<script>
import DeleteGroupModal from '@baserow/modules/core/components/group/DeleteGroupModal'
import GroupContext from '@baserow/modules/core/components/group/GroupContext'
import editGroup from '@baserow/modules/core/mixins/editGroup'
export default {
name: 'GroupsContextItem',
components: { DeleteGroupModal },
components: { GroupContext },
mixins: [editGroup],
props: {
group: {

View file

@ -2,7 +2,9 @@
<Modal :sidebar="true">
<template #sidebar>
<div class="modal-sidebar__head">
<div class="modal-sidebar__head-icon">{{ nameAbbreviation }}</div>
<div class="modal-sidebar__head-icon">
{{ name | nameAbbreviation }}
</div>
<div class="modal-sidebar__head-name">Settings</div>
</div>
<ul class="modal-sidebar__nav">
@ -54,7 +56,7 @@ export default {
return active ? active.getComponent() : null
},
...mapGetters({
nameAbbreviation: 'auth/getNameAbbreviation',
name: 'auth/getName',
}),
},
methods: {

View file

@ -0,0 +1,12 @@
/**
* Returns the two first characters of the provided first and last name.
*/
export default function (value) {
if (!value) {
return ''
}
const splitted = value.toString().toUpperCase().split(' ')
return splitted.length === 1
? splitted[0][0]
: splitted[0][0] + splitted[splitted.length - 1][0]
}

View file

@ -35,7 +35,7 @@
class="menu__link menu__user-item"
@click="$refs.userContext.toggle($event.target)"
>
{{ nameAbbreviation }}
{{ name | nameAbbreviation }}
<span class="menu__link-text">{{ name }}</span>
</a>
<Context ref="userContext">
@ -112,7 +112,6 @@ export default {
...mapGetters({
isCollapsed: 'sidebar/isCollapsed',
name: 'auth/getName',
nameAbbreviation: 'auth/getNameAbbreviation',
}),
},
mounted() {

View file

@ -30,6 +30,7 @@ export default {
*/
show() {
this.open = true
this.$emit('show')
window.addEventListener('keyup', this.keyup)
},
/**

View file

@ -15,6 +15,11 @@ export default {
required: false,
default: null,
},
description: {
type: String,
required: false,
default: null,
},
disabled: {
type: Boolean,
required: false,

View file

@ -41,9 +41,5 @@ export default {
this.setLoading(group, false)
},
deleteGroup(group) {
this.$refs.context.hide()
this.$refs.deleteGroupModal.show()
},
},
}

View file

@ -0,0 +1,22 @@
import GroupService from '@baserow/modules/core/services/group'
/**
* Mixin that fetches a group invitation based on the `groupInvitationToken` query
* parameter. If the token is not found, null will be added as invitation data value.
*/
export default {
async asyncData({ route, app }) {
const token = route.query.groupInvitationToken
if (token) {
try {
const { data: invitation } = await GroupService(
app.$client
).fetchInvitationByToken(token)
return { invitation }
} catch {}
}
return { invitation: null }
},
}

View file

@ -22,6 +22,12 @@
<i class="fa fa-heart"></i>
</a>
</div>
<GroupInvitation
v-for="invitation in groupInvitations"
:key="'invitation-' + invitation.id"
:invitation="invitation"
@remove="removeInvitation($event)"
></GroupInvitation>
<div v-if="groups.length === 0" class="placeholder">
<div class="placeholder__icon">
<i class="fas fa-layer-group"></i>
@ -60,10 +66,24 @@ import { mapState } from 'vuex'
import CreateGroupModal from '@baserow/modules/core/components/group/CreateGroupModal'
import DashboardGroup from '@baserow/modules/core/components/group/DashboardGroup'
import GroupInvitation from '@baserow/modules/core/components/group/GroupInvitation'
import AuthService from '@baserow/modules/core/services/auth'
export default {
components: { CreateGroupModal, DashboardGroup },
components: { CreateGroupModal, DashboardGroup, GroupInvitation },
layout: 'app',
/**
* Fetches the data that must be shown on the dashboard, this could for example be
* pending group invitations.
*/
async asyncData({ error, app }) {
try {
const { data } = await AuthService(app.$client).dashboard()
return { groupInvitations: data.group_invitations }
} catch (e) {
return error({ statusCode: 400, message: 'Error loading dashboard.' })
}
},
head() {
return {
title: 'Dashboard',
@ -76,5 +96,17 @@ export default {
applications: (state) => state.application.items,
}),
},
methods: {
/**
* When a group invation 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.groupInvitations.findIndex(
(i) => i.id === invitation.id
)
this.groupInvitations.splice(index, 1)
},
},
}
</script>

View file

@ -0,0 +1,35 @@
<script>
import GroupService from '@baserow/modules/core/services/group'
export default {
middleware: ['authentication'],
async asyncData({ store, params, error, app, redirect }) {
const { token } = params
let invitation
try {
const { data } = await GroupService(app.$client).fetchInvitationByToken(
token
)
invitation = data
} catch {
return error({ statusCode: 404, message: 'Invitation not found.' })
}
// If the authenticated user has the same email access we can accept the invitation
// right away and redirect to the dashboard.
if (
store.getters['auth/isAuthenticated'] &&
store.getters['auth/getUsername'] === invitation.email
) {
await GroupService(app.$client).acceptInvitation(invitation.id)
return redirect({ name: 'dashboard' })
}
// Depending on if the email address already exist we redirect the user to either
// the login or redirect page.
const name = invitation.email_exists ? 'login' : 'signup'
return redirect({ name, query: { groupInvitationToken: token } })
},
}
</script>

View file

@ -5,12 +5,35 @@
<img src="@baserow/modules/core/static/img/logo.svg" alt="" />
</nuxt-link>
</h1>
<div
v-if="invitation !== null"
class="alert alert--simple alert-primary alert--has-icon"
>
<div class="alert__icon">
<i class="fas fa-exclamation"></i>
</div>
<div class="alert__title">Invitation</div>
<p class="alert__content">
<strong>{{ invitation.invited_by }}</strong> has invited you to join
<strong>{{ invitation.group }}</strong
>.
</p>
</div>
<Error :error="error"></Error>
<form @submit.prevent="login">
<div class="control">
<label class="control__label">E-mail address</label>
<div class="control__elements">
<input
v-if="invitation !== null"
ref="email"
type="email"
class="input input--large"
disabled
:value="credentials.email"
/>
<input
v-else
ref="email"
v-model="credentials.email"
:class="{ 'input--error': $v.credentials.email.$error }"
@ -66,9 +89,11 @@
<script>
import { required, email } from 'vuelidate/lib/validators'
import error from '@baserow/modules/core/mixins/error'
import groupInvitationToken from '@baserow/modules/core/mixins/groupInvitationToken'
import GroupService from '@baserow/modules/core/services/group'
export default {
mixins: [error],
mixins: [error, groupInvitationToken],
layout: 'login',
data() {
return {
@ -90,6 +115,11 @@ export default {
],
}
},
beforeMount() {
if (this.invitation !== null) {
this.credentials.email = this.invitation.email
}
},
methods: {
async login() {
this.$v.$touch()
@ -105,6 +135,16 @@ export default {
email: this.credentials.email,
password: this.credentials.password,
})
// If there is an invitation we can immediately accept that one after the user
// successfully signs in.
if (
this.invitation !== null &&
this.invitation.email === this.credentials.email
) {
await GroupService(this.$client).acceptInvitation(this.invitation.id)
}
const { original } = this.$route.query
if (original) {
this.$nuxt.$router.push(original)

View file

@ -1,12 +1,35 @@
<template>
<div>
<h1 class="box__title">Sign up</h1>
<div
v-if="invitation !== null"
class="alert alert--simple alert-primary alert--has-icon"
>
<div class="alert__icon">
<i class="fas fa-exclamation"></i>
</div>
<div class="alert__title">Invitation</div>
<p class="alert__content">
<strong>{{ invitation.invited_by }}</strong> has invited you to join
<strong>{{ invitation.group }}</strong
>.
</p>
</div>
<Error :error="error"></Error>
<form @submit.prevent="register">
<div class="control">
<label class="control__label">E-mail address</label>
<div class="control__elements">
<input
v-if="invitation !== null"
ref="email"
type="email"
class="input input--large"
disabled
:value="account.email"
/>
<input
v-else
ref="email"
v-model="account.email"
:class="{ 'input--error': $v.account.email.$error }"
@ -107,10 +130,11 @@ import {
} from 'vuelidate/lib/validators'
import { ResponseErrorMessage } from '@baserow/modules/core/plugins/clientHandler'
import groupInvitationToken from '@baserow/modules/core/mixins/groupInvitationToken'
import error from '@baserow/modules/core/mixins/error'
export default {
mixins: [error],
mixins: [error, groupInvitationToken],
layout: 'login',
data() {
return {
@ -128,6 +152,11 @@ export default {
title: 'Create new account',
}
},
beforeMount() {
if (this.invitation !== null) {
this.account.email = this.invitation.email
}
},
methods: {
async register() {
this.$v.$touch()
@ -139,11 +168,21 @@ export default {
this.hideError()
try {
await this.$store.dispatch('auth/register', {
const values = {
name: this.account.name,
email: this.account.email,
password: this.account.password,
})
}
// If there is a valid invitation we can add the group invitation token to the
// action parameters so that is can be passed along when signing up. That makes
// the user accept the group invitation without creating a new group for the
// user.
if (this.invitation !== null) {
values.groupInvitationToken = this.$route.query.groupInvitationToken
}
await this.$store.dispatch('auth/register', values)
Object.values(this.$registry.getAll('plugin')).forEach((plugin) => {
plugin.userCreated(this.account, this)
})

View file

@ -134,7 +134,8 @@
<DropdownItem
name="Choice 2"
value="choice-2"
icon="pencil"
icon="edit"
description="Lorem ipsum dolor sit amet, consectetur."
></DropdownItem>
<DropdownItem
name="Choice 3"
@ -163,7 +164,7 @@
<DropdownItem
name="Choice 2"
value="choice-2"
icon="pencil"
icon="edit"
></DropdownItem>
<DropdownItem
name="Choice 3"
@ -544,25 +545,33 @@
</div>
<ul class="select__items">
<li class="select__item active">
<a href="#" class="select__item-link">Group name 1</a>
<a href="#" class="select__item-link">
<div class="select__item-name">Group name 1</div>
</a>
<a href="#" class="select__item-options">
<i class="fas fa-ellipsis-v"></i>
</a>
</li>
<li class="select__item">
<a href="#" class="select__item-link">Group name 2</a>
<a href="#" class="select__item-link">
<div class="select__item-name">Group name 2</div>
</a>
<a href="#" class="select__item-options">
<i class="fas fa-ellipsis-v"></i>
</a>
</li>
<li class="select__item select__item--loading">
<a href="#" class="select__item-link">Group name 3</a>
<a href="#" class="select__item-link">
<div class="select__item-name">Group name 3</div>
</a>
<a href="#" class="select__item-options">
<i class="fas fa-ellipsis-v"></i>
</a>
</li>
<li class="select__item">
<a href="#" class="select__item-link">Group name 4</a>
<a href="#" class="select__item-link">
<div class="select__item-name">roup name 4</div>
</a>
<a href="#" class="select__item-options">
<i class="fas fa-ellipsis-v"></i>
</a>
@ -587,10 +596,12 @@
<ul class="select__items">
<li class="select__item">
<a href="#" class="select__item-link">
<i
class="select__item-icon fas fa-th fa-fw color-primary"
></i>
Grid view name
<div class="select__item-name">
<i
class="select__item-icon fas fa-th fa-fw color-primary"
></i>
Grid view name
</div>
</a>
<a
href="#"
@ -602,10 +613,12 @@
</li>
<li class="select__item">
<a href="#" class="select__item-link">
<i
class="select__item-icon fas fa-th fa-fw color-primary"
></i>
Grid view option 2.
<div class="select__item-name">
<i
class="select__item-icon fas fa-th fa-fw color-primary"
></i>
Grid view option 2.
</div>
</a>
<a href="#" class="select__item-options">
<i class="fas fa-ellipsis-v"></i>
@ -613,10 +626,12 @@
</li>
<li class="select__item">
<a href="#" class="select__item-link">
<i
class="select__item-icon fas fa-th fa-fw color-primary"
></i>
Grid view 2
<div class="select__item-name">
<i
class="select__item-icon fas fa-th fa-fw color-primary"
></i>
Grid view 2
</div>
</a>
<a href="#" class="select__item-options">
<i class="fas fa-ellipsis-v"></i>
@ -624,10 +639,12 @@
</li>
<li class="select__item">
<a href="#" class="select__item-link">
<i
class="select__item-icon fas fa-th fa-fw color-primary"
></i>
Grid view 3
<div class="select__item-name">
<i
class="select__item-icon fas fa-th fa-fw color-primary"
></i>
Grid view 3
</div>
</a>
<a href="#" class="select__item-options">
<i class="fas fa-ellipsis-v"></i>

View file

@ -24,6 +24,11 @@ class ErrorHandler {
"The action couldn't be completed because you aren't a " +
'member of the related group.'
),
ERROR_USER_INVALID_GROUP_PERMISSIONS_ERROR: new ResponseErrorMessage(
'Action not allowed.',
"The action couldn't be completed because you don't have the right " +
'permissions to the related group.'
),
// @TODO move these errors to the module.
ERROR_TABLE_DOES_NOT_EXIST: new ResponseErrorMessage(
"Table doesn't exist.",

View file

@ -14,6 +14,7 @@ import Copied from '@baserow/modules/core/components/Copied'
import lowercase from '@baserow/modules/core/filters/lowercase'
import uppercase from '@baserow/modules/core/filters/uppercase'
import formatBytes from '@baserow/modules/core/filters/formatBytes'
import nameAbbreviation from '@baserow/modules/core/filters/nameAbbreviation'
import scroll from '@baserow/modules/core/directives/scroll'
import preventParentScroll from '@baserow/modules/core/directives/preventParentScroll'
@ -33,6 +34,7 @@ Vue.component('Copied', Copied)
Vue.filter('lowercase', lowercase)
Vue.filter('uppercase', uppercase)
Vue.filter('formatBytes', formatBytes)
Vue.filter('nameAbbreviation', nameAbbreviation)
Vue.directive('scroll', scroll)
Vue.directive('preventParentScroll', preventParentScroll)

View file

@ -177,7 +177,7 @@ export class RealTimeHandler {
})
this.registerEvent('application_created', ({ store }, data) => {
store.dispatch('application/forceCreate', { data: data.application })
store.dispatch('application/forceCreate', data.application)
})
this.registerEvent('application_updated', ({ store }, data) => {

View file

@ -31,6 +31,11 @@ export const routes = [
path: '/dashboard',
component: path.resolve(__dirname, 'pages/dashboard.vue'),
},
{
name: 'group-invitation',
path: '/group-invitation/:token',
component: path.resolve(__dirname, 'pages/groupInvitation.vue'),
},
{
name: 'style-guide',
path: '/style-guide',

View file

@ -11,13 +11,25 @@ export default (client) => {
token,
})
},
register(email, name, password, authenticate = true) {
return client.post('/user/', {
register(
email,
name,
password,
authenticate = true,
groupInvitationToken = null
) {
const values = {
name,
email,
password,
authenticate,
})
}
if (groupInvitationToken !== null) {
values.group_invitation_token = groupInvitationToken
}
return client.post('/user/', values)
},
sendResetPasswordEmail(email, baseUrl) {
return client.post('/user/send-reset-password-email/', {
@ -37,5 +49,8 @@ export default (client) => {
new_password: newPassword,
})
},
dashboard() {
return client.get('/user/dashboard/')
},
}
}

View file

@ -12,5 +12,36 @@ export default (client) => {
delete(id) {
return client.delete(`/groups/${id}/`)
},
sendInvitation(groupId, baseUrl, values) {
values.base_url = baseUrl
return client.post(`/groups/invitations/group/${groupId}/`, values)
},
fetchAllUsers(groupId) {
return client.get(`/groups/users/group/${groupId}/`)
},
updateUser(groupUserId, values) {
return client.patch(`/groups/users/${groupUserId}/`, values)
},
deleteUser(groupUserId) {
return client.delete(`/groups/users/${groupUserId}/`)
},
fetchAllInvitations(groupId) {
return client.get(`/groups/invitations/group/${groupId}/`)
},
fetchInvitationByToken(token) {
return client.get(`/groups/invitations/token/${token}/`)
},
updateInvitation(invitationId, values) {
return client.patch(`/groups/invitations/${invitationId}/`, values)
},
deleteInvitation(invitationId) {
return client.delete(`/groups/invitations/${invitationId}/`)
},
rejectInvitation(invitationId) {
return client.post(`/groups/invitations/${invitationId}/reject/`)
},
acceptInvitation(invitationId) {
return client.post(`/groups/invitations/${invitationId}/accept/`)
},
}
}

View file

@ -71,7 +71,7 @@ export const actions = {
/**
* Fetches all the application of the authenticated user.
*/
async fetchAll({ commit, getters }) {
async fetchAll({ commit }) {
commit('SET_LOADING', true)
try {
@ -134,12 +134,12 @@ export const actions = {
group.id,
postData
)
dispatch('forceCreate', { data })
dispatch('forceCreate', data)
},
/**
* Forcefully create an item in the store without making a call to the server.
*/
forceCreate({ commit }, { data }) {
forceCreate({ commit }, data) {
populateApplication(data, this.$registry)
commit('ADD_ITEM', data)
},

View file

@ -44,12 +44,16 @@ export const actions = {
* Register a new user and immediately authenticate. If successful commit the
* token to the state and start the refresh timeout to stay authenticated.
*/
async register({ commit, dispatch }, { email, name, password }) {
async register(
{ commit, dispatch },
{ email, name, password, groupInvitationToken = null }
) {
const { data } = await AuthService(this.$client).register(
email,
name,
password,
true
true,
groupInvitationToken
)
setToken(data.token, this.app)
commit('SET_USER_DATA', data)
@ -132,11 +136,8 @@ export const getters = {
getName(state) {
return state.user ? state.user.first_name : ''
},
getNameAbbreviation(state) {
return state.user ? state.user.first_name.split('')[0] : ''
},
getEmail(state) {
return state.user ? state.user.email : ''
getUsername(state) {
return state.user ? state.user.username : ''
},
/**
* Returns the amount of seconds it will take before the tokes expires.

View file

@ -41,7 +41,7 @@
<div class="filters__field">
<Dropdown
:value="filter.field"
class="dropdown--floating"
class="dropdown--floating dropdown--tiny"
@input="updateFilter(filter, { field: $event })"
>
<DropdownItem
@ -62,7 +62,7 @@
<div class="filters__type">
<Dropdown
:value="filter.type"
class="dropdown--floating"
class="dropdown--floating dropdown--tiny"
@input="updateFilter(filter, { type: $event })"
>
<DropdownItem

View file

@ -26,7 +26,7 @@
<div class="sortings__field">
<Dropdown
:value="sort.field"
class="dropdown--floating"
class="dropdown--floating dropdown--tiny"
@input="updateSort(sort, { field: $event })"
>
<DropdownItem

View file

@ -7,15 +7,17 @@
}"
>
<a class="select__item-link" @click="selectView(view)">
<i
class="select__item-icon fas fa-fw color-primary"
:class="'fa-' + view._.type.iconClass"
></i>
<Editable
ref="rename"
:value="view.name"
@change="renameView(view, $event)"
></Editable>
<div class="select__item-name">
<i
class="select__item-icon fas fa-fw color-primary"
:class="'fa-' + view._.type.iconClass"
></i>
<Editable
ref="rename"
:value="view.name"
@change="renameView(view, $event)"
></Editable>
</div>
</a>
<a
ref="contextLink"

View file

@ -492,14 +492,34 @@ export class NumberFieldType extends FieldType {
getSort(name, order) {
return (a, b) => {
const numberA = parseFloat(a[name])
const numberB = parseFloat(b[name])
if (a[name] === b[name]) {
return 0
}
if (isNaN(numberA) || isNaN(numberB)) {
if (
(a[name] === null && order === 'ASC') ||
(b[name] === null && order === 'DESC')
) {
return -1
}
return order === 'ASC' ? numberA - numberB : numberB - numberA
if (
(b[name] === null && order === 'ASC') ||
(a[name] === null && order === 'DESC')
) {
return 1
}
const numberA = new BigNumber(a[name])
const numberB = new BigNumber(b[name])
return order === 'ASC'
? numberA.isLessThan(numberB)
? -1
: 1
: numberB.isLessThan(numberA)
? -1
: 1
}
}
@ -659,13 +679,28 @@ export class DateFieldType extends FieldType {
getSort(name, order) {
return (a, b) => {
if (a[name] === null || b[name] === null) {
if (a[name] === b[name]) {
return 0
}
if (
(a[name] === null && order === 'ASC') ||
(b[name] === null && order === 'DESC')
) {
return -1
}
if (
(b[name] === null && order === 'ASC') ||
(a[name] === null && order === 'DESC')
) {
return 1
}
const timeA = new Date(a[name]).getTime()
const timeB = new Date(b[name]).getTime()
return order === 'ASC' ? timeA - timeB : timeB - timeA
return order === 'ASC' ? (timeA < timeB ? -1 : 1) : timeB < timeA ? -1 : 1
}
}

View file

@ -143,8 +143,13 @@ export const mutations = {
},
DELETE_ROW(state, id) {
const index = state.rows.findIndex((item) => item.id === id)
state.count--
if (index !== -1) {
// A small side effect of the buffered loading is that we don't know for sure if
// the row exists within the view. So the count might need to be decreased
// even though the row is not found. Because we don't want to make another call
// to the backend we only decrease the count if the row is found in the buffer.
// The count is eventually refreshed when the user scrolls within the view.
state.count--
state.bufferLimit--
state.rows.splice(index, 1)
}