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 #272, #277, and #138 See merge request bramw/baserow!147
This commit is contained in:
commit
b90b81c327
88 changed files with 4260 additions and 316 deletions
backend
src/baserow
api
config/settings
contrib/database
core
emails.pyexceptions.pyhandler.py
migrations
models.pyregistries.pysignals.pytemplates/baserow/core
user
ws
tests
web-frontend/modules
core
assets/scss/components
all.scssdashboard.scssdropdown.scssgroup_member.scssmenu.scssmodal_sidebar.scssquote.scssselect.scssseparator.scss
components
DropdownItem.vue
group
DashboardGroup.vueGroupContext.vueGroupInvitation.vueGroupInviteForm.vueGroupMember.vueGroupMembersModal.vueGroupsContextItem.vue
settings
filters
layouts
mixins
pages
plugins
routes.jsservices
store
database
|
@ -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)
|
||||
|
||||
|
|
|
@ -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.'
|
||||
)
|
||||
|
|
0
backend/src/baserow/api/groups/__init__.py
Normal file
0
backend/src/baserow/api/groups/__init__.py
Normal file
0
backend/src/baserow/api/groups/invitations/__init__.py
Normal file
0
backend/src/baserow/api/groups/invitations/__init__.py
Normal file
13
backend/src/baserow/api/groups/invitations/errors.py
Normal file
13
backend/src/baserow/api/groups/invitations/errors.py
Normal 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.'
|
||||
)
|
66
backend/src/baserow/api/groups/invitations/serializers.py
Normal file
66
backend/src/baserow/api/groups/invitations/serializers.py
Normal 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
|
34
backend/src/baserow/api/groups/invitations/urls.py
Normal file
34
backend/src/baserow/api/groups/invitations/urls.py
Normal 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'
|
||||
),
|
||||
]
|
407
backend/src/baserow/api/groups/invitations/views.py
Normal file
407
backend/src/baserow/api/groups/invitations/views.py
Normal 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)
|
|
@ -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(),
|
||||
|
|
|
@ -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'),
|
||||
]
|
||||
|
|
0
backend/src/baserow/api/groups/users/__init__.py
Normal file
0
backend/src/baserow/api/groups/users/__init__.py
Normal file
13
backend/src/baserow/api/groups/users/errors.py
Normal file
13
backend/src/baserow/api/groups/users/errors.py
Normal 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.'
|
||||
)
|
47
backend/src/baserow/api/groups/users/serializers.py
Normal file
47
backend/src/baserow/api/groups/users/serializers.py
Normal 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',)
|
11
backend/src/baserow/api/groups/users/urls.py
Normal file
11
backend/src/baserow/api/groups/users/urls.py
Normal 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'),
|
||||
]
|
160
backend/src/baserow/api/groups/users/views.py
Normal file
160
backend/src/baserow/api/groups/users/views.py
Normal 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)
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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.'
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'},
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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.
|
||||
|
|
123
backend/src/baserow/core/migrations/0004_auto_20210126_1950.py
Normal file
123
backend/src/baserow/core/migrations/0004_auto_20210126_1950.py
Normal 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),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
562
backend/tests/baserow/api/groups/test_group_invitation_views.py
Normal file
562
backend/tests/baserow/api/groups/test_group_invitation_views.py
Normal 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
|
190
backend/tests/baserow/api/groups/test_group_user_views.py
Normal file
190
backend/tests/baserow/api/groups/test_group_user_views.py
Normal 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
|
|
@ -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,
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
23
backend/tests/fixtures/group.py
vendored
23
backend/tests/fixtures/group.py
vendored
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -58,3 +58,6 @@
|
|||
@import 'select_options';
|
||||
@import 'select_options_listing';
|
||||
@import 'color_select';
|
||||
@import 'group_member';
|
||||
@import 'separator';
|
||||
@import 'quote';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -60,5 +60,5 @@
|
|||
color: $white;
|
||||
font-weight: 700;
|
||||
|
||||
@include center-text(32px, 18px);
|
||||
@include center-text(32px, 13px);
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
color: $white;
|
||||
font-weight: 700;
|
||||
|
||||
@include center-text(32px, 18px);
|
||||
@include center-text(32px, 13px);
|
||||
}
|
||||
|
||||
.modal-sidebar__head-name {
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
.quote {
|
||||
border-left: solid 4px $color-neutral-200;
|
||||
padding: 4px 0 4px 10px;
|
||||
margin: 10px 0;
|
||||
font-style: italic;
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
.separator {
|
||||
margin: 30px 0;
|
||||
border-bottom: solid 1px $color-neutral-200;
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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: {
|
||||
|
|
52
web-frontend/modules/core/components/group/GroupContext.vue
Normal file
52
web-frontend/modules/core/components/group/GroupContext.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
94
web-frontend/modules/core/components/group/GroupMember.vue
Normal file
94
web-frontend/modules/core/components/group/GroupMember.vue
Normal 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>
|
243
web-frontend/modules/core/components/group/GroupMembersModal.vue
Normal file
243
web-frontend/modules/core/components/group/GroupMembersModal.vue
Normal 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>
|
|
@ -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: {
|
||||
|
|
|
@ -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: {
|
||||
|
|
12
web-frontend/modules/core/filters/nameAbbreviation.js
Normal file
12
web-frontend/modules/core/filters/nameAbbreviation.js
Normal 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]
|
||||
}
|
|
@ -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() {
|
||||
|
|
|
@ -30,6 +30,7 @@ export default {
|
|||
*/
|
||||
show() {
|
||||
this.open = true
|
||||
this.$emit('show')
|
||||
window.addEventListener('keyup', this.keyup)
|
||||
},
|
||||
/**
|
||||
|
|
|
@ -15,6 +15,11 @@ export default {
|
|||
required: false,
|
||||
default: null,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
|
|
|
@ -41,9 +41,5 @@ export default {
|
|||
|
||||
this.setLoading(group, false)
|
||||
},
|
||||
deleteGroup(group) {
|
||||
this.$refs.context.hide()
|
||||
this.$refs.deleteGroupModal.show()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
22
web-frontend/modules/core/mixins/groupInvitationToken.js
Normal file
22
web-frontend/modules/core/mixins/groupInvitationToken.js
Normal 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 }
|
||||
},
|
||||
}
|
|
@ -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>
|
||||
|
|
35
web-frontend/modules/core/pages/groupInvitation.vue
Normal file
35
web-frontend/modules/core/pages/groupInvitation.vue
Normal 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>
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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/')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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/`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue