Invite users #15
Some checks failed
Test API before pr merge / test-lint (pull_request) Failing after 11s
Test before pr merge / test-lint (pull_request) Failing after 41s
Test before pr merge / test-translation-lint (pull_request) Successful in 40s
Test before pr merge / test-before-merge (pull_request) Failing after 1m30s
Some checks failed
Test API before pr merge / test-lint (pull_request) Failing after 11s
Test before pr merge / test-lint (pull_request) Failing after 41s
Test before pr merge / test-translation-lint (pull_request) Successful in 40s
Test before pr merge / test-before-merge (pull_request) Failing after 1m30s
This commit is contained in:
parent
b815710dd6
commit
3f7a398392
33
api/src/api/routes/invitation.py
Normal file
33
api/src/api/routes/invitation.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import RedirectResponse, JSONResponse
|
||||||
|
|
||||||
|
from api.route import Route
|
||||||
|
from core.environment import Environment
|
||||||
|
from core.logger import Logger
|
||||||
|
from data.schemas.public.user_space_user import UserSpaceUser
|
||||||
|
from data.schemas.public.user_space_user_dao import userSpaceUserDao
|
||||||
|
|
||||||
|
BasePath = f"/invitation"
|
||||||
|
logger = Logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@Route.get(f"{BasePath}/accept/{{invitation_id:path}}")
|
||||||
|
async def accept_invitation(request: Request):
|
||||||
|
invitation_id = request.path_params["invitation_id"]
|
||||||
|
|
||||||
|
user_space_user = await userSpaceUserDao.find_single_by(
|
||||||
|
[{UserSpaceUser.invitation: invitation_id}, {UserSpaceUser.deleted: False}]
|
||||||
|
)
|
||||||
|
if user_space_user is None:
|
||||||
|
return JSONResponse({"error": "Invitation not found or already accepted."})
|
||||||
|
|
||||||
|
user_space_user.invitation = None
|
||||||
|
await userSpaceUserDao.update(user_space_user)
|
||||||
|
|
||||||
|
client_urls = Environment.get("CLIENT_URLS", str)
|
||||||
|
if client_urls is None:
|
||||||
|
return JSONResponse(
|
||||||
|
{"message": "Invitation accepted, but no client URLs configured."}
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(client_urls.split(",")[0], status_code=303)
|
@ -75,7 +75,7 @@ type UserSpaceMutation {
|
|||||||
delete(id: Int!): Boolean
|
delete(id: Int!): Boolean
|
||||||
restore(id: Int!): Boolean
|
restore(id: Int!): Boolean
|
||||||
|
|
||||||
inviteUsers(emails: [String!]!): Boolean
|
inviteUsers(id: Int!, emails: [String!]!): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
input UserSpaceCreateInput {
|
input UserSpaceCreateInput {
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from api.route import Route
|
from api.route import Route
|
||||||
from api_graphql.abc.mutation_abc import MutationABC
|
from api_graphql.abc.mutation_abc import MutationABC
|
||||||
from api_graphql.field.mutation_field_builder import MutationFieldBuilder
|
from api_graphql.field.mutation_field_builder import MutationFieldBuilder
|
||||||
@ -7,6 +9,8 @@ from api_graphql.service.query_context import QueryContext
|
|||||||
from core.logger import APILogger
|
from core.logger import APILogger
|
||||||
from core.string import first_to_lower
|
from core.string import first_to_lower
|
||||||
from data.schemas.administration.user_dao import userDao
|
from data.schemas.administration.user_dao import userDao
|
||||||
|
from data.schemas.public.user_invitation import UserInvitation
|
||||||
|
from data.schemas.public.user_invitation_dao import userInvitationDao
|
||||||
from data.schemas.public.user_space import UserSpace
|
from data.schemas.public.user_space import UserSpace
|
||||||
from data.schemas.public.user_space_dao import userSpaceDao
|
from data.schemas.public.user_space_dao import userSpaceDao
|
||||||
from data.schemas.public.user_space_user import UserSpaceUser
|
from data.schemas.public.user_space_user import UserSpaceUser
|
||||||
@ -71,7 +75,7 @@ class UserSpaceMutation(MutationABC):
|
|||||||
f"{first_to_lower(self.name.replace("Mutation", ""))}Change"
|
f"{first_to_lower(self.name.replace("Mutation", ""))}Change"
|
||||||
)
|
)
|
||||||
.with_require_any(
|
.with_require_any(
|
||||||
[Permissions.user_spaces_create, Permissions.user_spaces_update],
|
[Permissions.user_spaces_update],
|
||||||
[self._resolve_input_user_space_assigned],
|
[self._resolve_input_user_space_assigned],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -141,8 +145,51 @@ class UserSpaceMutation(MutationABC):
|
|||||||
await userSpaceDao.restore(user_space)
|
await userSpaceDao.restore(user_space)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def resolve_invite_users(self, emails: list[str], *_):
|
@staticmethod
|
||||||
pass
|
async def resolve_invite_users(emails: list[str], *_, id: str):
|
||||||
|
logger.debug(f"invite users to user_space: {id} with emails: {emails}")
|
||||||
|
|
||||||
|
user_space = await userSpaceDao.get_by_id(id)
|
||||||
|
user_space_user_emails = [
|
||||||
|
x.email for x in await userSpaceDao.get_users(user_space.id)
|
||||||
|
]
|
||||||
|
|
||||||
|
email_invitations = {}
|
||||||
|
|
||||||
|
for email in emails:
|
||||||
|
if email in user_space_user_emails:
|
||||||
|
continue
|
||||||
|
user = await userDao.find_single_by({"email": {"equal": email}})
|
||||||
|
|
||||||
|
invitation = uuid4()
|
||||||
|
if user is None:
|
||||||
|
user_invitation = await userInvitationDao.find_by(
|
||||||
|
[
|
||||||
|
{UserInvitation.email: email},
|
||||||
|
{UserInvitation.user_space_id: user_space.id},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if len(user_invitation) > 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
await userInvitationDao.create(
|
||||||
|
UserInvitation(
|
||||||
|
0,
|
||||||
|
email,
|
||||||
|
str(invitation),
|
||||||
|
user_space.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
email_invitations[email] = invitation
|
||||||
|
await userSpaceUserDao.create(
|
||||||
|
UserSpaceUser(0, user_space.id, user.id, str(invitation))
|
||||||
|
)
|
||||||
|
|
||||||
|
# todo: send actual emails
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _resolve_input_user_space_assigned(ctx: QueryContext):
|
async def _resolve_input_user_space_assigned(ctx: QueryContext):
|
||||||
|
@ -18,4 +18,4 @@ class UserSpaceQuery(DbModelQueryABC):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get_users(space: UserSpace, *_):
|
async def _get_users(space: UserSpace, *_):
|
||||||
return await userSpaceDao.get_users(space.id)
|
return await userSpaceDao.get_users(space.id, with_invitations=False)
|
||||||
|
@ -64,7 +64,7 @@ class Subscription(SubscriptionABC):
|
|||||||
self.subscribe(
|
self.subscribe(
|
||||||
SubscriptionFieldBuilder("userSpaceChange").with_resolver(
|
SubscriptionFieldBuilder("userSpaceChange").with_resolver(
|
||||||
lambda message, *_: message.message
|
lambda message, *_: message.message
|
||||||
)
|
).with_public(True)
|
||||||
)
|
)
|
||||||
self.subscribe(
|
self.subscribe(
|
||||||
SubscriptionFieldBuilder("groupChange")
|
SubscriptionFieldBuilder("groupChange")
|
||||||
|
51
api/src/data/schemas/public/user_invitation.py
Normal file
51
api/src/data/schemas/public/user_invitation.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from async_property import async_property
|
||||||
|
|
||||||
|
from core.database.abc.db_model_abc import DbModelABC
|
||||||
|
from core.typing import SerialId
|
||||||
|
|
||||||
|
|
||||||
|
class UserInvitation(DbModelABC):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
id: SerialId,
|
||||||
|
email: str,
|
||||||
|
invitation: str,
|
||||||
|
user_space_id: SerialId,
|
||||||
|
deleted: bool = False,
|
||||||
|
editor_id: Optional[SerialId] = None,
|
||||||
|
created: Optional[datetime] = None,
|
||||||
|
updated: Optional[datetime] = None,
|
||||||
|
):
|
||||||
|
DbModelABC.__init__(self, id, deleted, editor_id, created, updated)
|
||||||
|
self._email = email
|
||||||
|
self._user_space_id = user_space_id
|
||||||
|
self._invitation = invitation
|
||||||
|
|
||||||
|
@property
|
||||||
|
def email(self) -> str:
|
||||||
|
return self._email
|
||||||
|
|
||||||
|
@email.setter
|
||||||
|
def email(self, value: str):
|
||||||
|
self._email = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def invitation(self) -> Optional[str]:
|
||||||
|
return self._invitation
|
||||||
|
|
||||||
|
@invitation.setter
|
||||||
|
def invitation(self, value: Optional[str]):
|
||||||
|
self._invitation = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_space_id(self) -> SerialId:
|
||||||
|
return self._user_space_id
|
||||||
|
|
||||||
|
@async_property
|
||||||
|
async def user_space(self):
|
||||||
|
from data.schemas.public.user_space_dao import userSpaceDao
|
||||||
|
|
||||||
|
return await userSpaceDao.get_by_id(self._user_space_id)
|
16
api/src/data/schemas/public/user_invitation_dao.py
Normal file
16
api/src/data/schemas/public/user_invitation_dao.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from core.database.abc.db_model_dao_abc import DbModelDaoABC
|
||||||
|
from data.schemas.public.user_invitation import UserInvitation
|
||||||
|
|
||||||
|
|
||||||
|
class UserInvitationDao(DbModelDaoABC[UserInvitation]):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
DbModelDaoABC.__init__(
|
||||||
|
self, __name__, UserInvitation, "public.user_invitations"
|
||||||
|
)
|
||||||
|
self.attribute(UserInvitation.email, str)
|
||||||
|
self.attribute(UserInvitation.invitation, str)
|
||||||
|
self.attribute(UserInvitation.user_space_id, int)
|
||||||
|
|
||||||
|
|
||||||
|
userInvitationDao = UserInvitationDao()
|
@ -17,7 +17,7 @@ class UserSpaceDao(DbModelDaoABC[UserSpace]):
|
|||||||
)
|
)
|
||||||
return self.to_object(result[0])
|
return self.to_object(result[0])
|
||||||
|
|
||||||
async def get_users(self, user_space_id: int) -> list:
|
async def get_users(self, user_space_id: int, with_invitations=True) -> list:
|
||||||
result = await self._db.select_map(
|
result = await self._db.select_map(
|
||||||
f"""
|
f"""
|
||||||
SELECT u.*
|
SELECT u.*
|
||||||
@ -25,6 +25,7 @@ class UserSpaceDao(DbModelDaoABC[UserSpace]):
|
|||||||
JOIN public.user_spaces_users usu ON u.id = usu.userId
|
JOIN public.user_spaces_users usu ON u.id = usu.userId
|
||||||
WHERE usu.userSpaceId = {user_space_id}
|
WHERE usu.userSpaceId = {user_space_id}
|
||||||
AND usu.deleted = FALSE
|
AND usu.deleted = FALSE
|
||||||
|
{'' if with_invitations else 'AND usu.invitation IS NULL'}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ class UserSpaceUser(DbJoinModelABC):
|
|||||||
id: SerialId,
|
id: SerialId,
|
||||||
user_space_id: SerialId,
|
user_space_id: SerialId,
|
||||||
user_id: SerialId,
|
user_id: SerialId,
|
||||||
|
invitation: Optional[str] = None,
|
||||||
deleted: bool = False,
|
deleted: bool = False,
|
||||||
editor_id: Optional[SerialId] = None,
|
editor_id: Optional[SerialId] = None,
|
||||||
created: Optional[datetime] = None,
|
created: Optional[datetime] = None,
|
||||||
@ -24,6 +25,7 @@ class UserSpaceUser(DbJoinModelABC):
|
|||||||
)
|
)
|
||||||
self._user_space_id = user_space_id
|
self._user_space_id = user_space_id
|
||||||
self._user_id = user_id
|
self._user_id = user_id
|
||||||
|
self._invitation = invitation
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def user_space_id(self) -> SerialId:
|
def user_space_id(self) -> SerialId:
|
||||||
@ -44,3 +46,11 @@ class UserSpaceUser(DbJoinModelABC):
|
|||||||
from data.schemas.administration.user_dao import userDao
|
from data.schemas.administration.user_dao import userDao
|
||||||
|
|
||||||
return await userDao.get_by_id(self._user_id)
|
return await userDao.get_by_id(self._user_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def invitation(self) -> Optional[str]:
|
||||||
|
return self._invitation
|
||||||
|
|
||||||
|
@invitation.setter
|
||||||
|
def invitation(self, value: Optional[str]):
|
||||||
|
self._invitation = value
|
||||||
|
@ -12,11 +12,15 @@ class UserSpaceUserDao(DbModelDaoABC[UserSpaceUser]):
|
|||||||
)
|
)
|
||||||
self.attribute(UserSpaceUser.user_space_id, int)
|
self.attribute(UserSpaceUser.user_space_id, int)
|
||||||
self.attribute(UserSpaceUser.user_id, int)
|
self.attribute(UserSpaceUser.user_id, int)
|
||||||
|
self.attribute(UserSpaceUser.invitation, str)
|
||||||
|
|
||||||
async def get_by_user_space_id(
|
async def get_by_user_space_id(
|
||||||
self, user_space_id: str, with_deleted=False
|
self, user_space_id: str, with_deleted=False
|
||||||
) -> list[UserSpaceUser]:
|
) -> list[UserSpaceUser]:
|
||||||
f = [{UserSpaceUser.user_space_id: user_space_id}]
|
f = [
|
||||||
|
{UserSpaceUser.user_space_id: user_space_id},
|
||||||
|
{UserSpaceUser.invitation: None},
|
||||||
|
]
|
||||||
if not with_deleted:
|
if not with_deleted:
|
||||||
f.append({UserSpaceUser.deleted: False})
|
f.append({UserSpaceUser.deleted: False})
|
||||||
|
|
||||||
@ -25,7 +29,7 @@ class UserSpaceUserDao(DbModelDaoABC[UserSpaceUser]):
|
|||||||
async def get_by_user_id(
|
async def get_by_user_id(
|
||||||
self, user_id: str, with_deleted=False
|
self, user_id: str, with_deleted=False
|
||||||
) -> list[UserSpaceUser]:
|
) -> list[UserSpaceUser]:
|
||||||
f = [{UserSpaceUser.user_id: user_id}]
|
f = [{UserSpaceUser.user_id: user_id}, {UserSpaceUser.invitation: None}]
|
||||||
if not with_deleted:
|
if not with_deleted:
|
||||||
f.append({UserSpaceUser.deleted: False})
|
f.append({UserSpaceUser.deleted: False})
|
||||||
|
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE public.user_spaces_users
|
||||||
|
ADD COLUMN invitation UUID DEFAULT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE public.user_spaces_users_history
|
||||||
|
ADD COLUMN invitation UUID DEFAULT NULL;
|
@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE public.user_invitations
|
||||||
|
(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
invitation UUID NOT NULL,
|
||||||
|
userSpaceId INT NOT NULL REFERENCES public.user_spaces (Id),
|
||||||
|
-- for history
|
||||||
|
Deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
EditorId INT NULL REFERENCES administration.users (Id),
|
||||||
|
Created timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
Updated timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE public.user_invitations_history
|
||||||
|
(
|
||||||
|
LIKE public.user_invitations
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER user_invitations_history_trigger
|
||||||
|
BEFORE INSERT OR UPDATE OR DELETE
|
||||||
|
ON public.user_invitations
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.history_trigger_function();
|
@ -25,12 +25,14 @@ export class UsersColumns extends PageColumns<User> {
|
|||||||
translationKey: 'user.username',
|
translationKey: 'user.username',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
value: (row: User) => row.username,
|
value: (row: User) => row.username,
|
||||||
|
filterable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'email',
|
name: 'email',
|
||||||
translationKey: 'user.email',
|
translationKey: 'user.email',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
value: (row: User) => row.email,
|
value: (row: User) => row.email,
|
||||||
|
filterable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'roles',
|
name: 'roles',
|
||||||
|
@ -80,14 +80,14 @@ export class UserSpaceFormPageComponent extends FormPageBase<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected handleEMailInvitation() {
|
protected handleEMailInvitation(id: number): void {
|
||||||
const emailsToInvite = this.form.controls['emailsToInvite'].value;
|
const emailsToInvite = this.form.controls['emailsToInvite'].value;
|
||||||
if (!(emailsToInvite && emailsToInvite.length > 0)) {
|
if (!(emailsToInvite && emailsToInvite.length > 0)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.inviteUsers(emailsToInvite)
|
.inviteUsers(id, emailsToInvite)
|
||||||
.pipe(
|
.pipe(
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
this.spinner.hide();
|
this.spinner.hide();
|
||||||
@ -102,7 +102,6 @@ export class UserSpaceFormPageComponent extends FormPageBase<
|
|||||||
}
|
}
|
||||||
|
|
||||||
create(object: UserSpaceCreateInput): void {
|
create(object: UserSpaceCreateInput): void {
|
||||||
this.handleEMailInvitation();
|
|
||||||
this.dataService.create(object).subscribe(() => {
|
this.dataService.create(object).subscribe(() => {
|
||||||
this.spinner.hide();
|
this.spinner.hide();
|
||||||
this.toast.success('action.created');
|
this.toast.success('action.created');
|
||||||
@ -111,7 +110,7 @@ export class UserSpaceFormPageComponent extends FormPageBase<
|
|||||||
}
|
}
|
||||||
|
|
||||||
update(object: UserSpaceUpdateInput): void {
|
update(object: UserSpaceUpdateInput): void {
|
||||||
this.handleEMailInvitation();
|
this.handleEMailInvitation(object.id);
|
||||||
this.dataService.update(object).subscribe(() => {
|
this.dataService.update(object).subscribe(() => {
|
||||||
this.spinner.hide();
|
this.spinner.hide();
|
||||||
this.toast.success('action.created');
|
this.toast.success('action.created');
|
||||||
|
@ -290,17 +290,18 @@ export class UserSpacesDataService
|
|||||||
.pipe(map(result => result.data?.userSpace.restore ?? false));
|
.pipe(map(result => result.data?.userSpace.restore ?? false));
|
||||||
}
|
}
|
||||||
|
|
||||||
inviteUsers(emails: string[]): Observable<boolean> {
|
inviteUsers(id: number, emails: string[]): Observable<boolean> {
|
||||||
return this.apollo
|
return this.apollo
|
||||||
.mutate<{ userSpace: { inviteUsers: boolean } }>({
|
.mutate<{ userSpace: { inviteUsers: boolean } }>({
|
||||||
mutation: gql`
|
mutation: gql`
|
||||||
mutation inviteUsers($emails: [String!]!) {
|
mutation inviteUsers($id: Int!, $emails: [String!]!) {
|
||||||
userSpace {
|
userSpace {
|
||||||
inviteUsers(emails: $emails)
|
inviteUsers(id: $id, emails: $emails)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
variables: {
|
variables: {
|
||||||
|
id,
|
||||||
emails,
|
emails,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
Loading…
Reference in New Issue
Block a user