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
|
||||
restore(id: Int!): Boolean
|
||||
|
||||
inviteUsers(emails: [String!]!): Boolean
|
||||
inviteUsers(id: Int!, emails: [String!]!): Boolean
|
||||
}
|
||||
|
||||
input UserSpaceCreateInput {
|
||||
|
@ -1,3 +1,5 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from api.route import Route
|
||||
from api_graphql.abc.mutation_abc import MutationABC
|
||||
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.string import first_to_lower
|
||||
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_dao import userSpaceDao
|
||||
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"
|
||||
)
|
||||
.with_require_any(
|
||||
[Permissions.user_spaces_create, Permissions.user_spaces_update],
|
||||
[Permissions.user_spaces_update],
|
||||
[self._resolve_input_user_space_assigned],
|
||||
)
|
||||
)
|
||||
@ -141,8 +145,51 @@ class UserSpaceMutation(MutationABC):
|
||||
await userSpaceDao.restore(user_space)
|
||||
return True
|
||||
|
||||
async def resolve_invite_users(self, emails: list[str], *_):
|
||||
pass
|
||||
@staticmethod
|
||||
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
|
||||
async def _resolve_input_user_space_assigned(ctx: QueryContext):
|
||||
|
@ -18,4 +18,4 @@ class UserSpaceQuery(DbModelQueryABC):
|
||||
|
||||
@staticmethod
|
||||
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(
|
||||
SubscriptionFieldBuilder("userSpaceChange").with_resolver(
|
||||
lambda message, *_: message.message
|
||||
)
|
||||
).with_public(True)
|
||||
)
|
||||
self.subscribe(
|
||||
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])
|
||||
|
||||
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(
|
||||
f"""
|
||||
SELECT u.*
|
||||
@ -25,6 +25,7 @@ class UserSpaceDao(DbModelDaoABC[UserSpace]):
|
||||
JOIN public.user_spaces_users usu ON u.id = usu.userId
|
||||
WHERE usu.userSpaceId = {user_space_id}
|
||||
AND usu.deleted = FALSE
|
||||
{'' if with_invitations else 'AND usu.invitation IS NULL'}
|
||||
"""
|
||||
)
|
||||
|
||||
|
@ -14,6 +14,7 @@ class UserSpaceUser(DbJoinModelABC):
|
||||
id: SerialId,
|
||||
user_space_id: SerialId,
|
||||
user_id: SerialId,
|
||||
invitation: Optional[str] = None,
|
||||
deleted: bool = False,
|
||||
editor_id: Optional[SerialId] = None,
|
||||
created: Optional[datetime] = None,
|
||||
@ -24,6 +25,7 @@ class UserSpaceUser(DbJoinModelABC):
|
||||
)
|
||||
self._user_space_id = user_space_id
|
||||
self._user_id = user_id
|
||||
self._invitation = invitation
|
||||
|
||||
@property
|
||||
def user_space_id(self) -> SerialId:
|
||||
@ -44,3 +46,11 @@ class UserSpaceUser(DbJoinModelABC):
|
||||
from data.schemas.administration.user_dao import userDao
|
||||
|
||||
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_id, int)
|
||||
self.attribute(UserSpaceUser.invitation, str)
|
||||
|
||||
async def get_by_user_space_id(
|
||||
self, user_space_id: str, with_deleted=False
|
||||
) -> 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:
|
||||
f.append({UserSpaceUser.deleted: False})
|
||||
|
||||
@ -25,7 +29,7 @@ class UserSpaceUserDao(DbModelDaoABC[UserSpaceUser]):
|
||||
async def get_by_user_id(
|
||||
self, user_id: str, with_deleted=False
|
||||
) -> list[UserSpaceUser]:
|
||||
f = [{UserSpaceUser.user_id: user_id}]
|
||||
f = [{UserSpaceUser.user_id: user_id}, {UserSpaceUser.invitation: None}]
|
||||
if not with_deleted:
|
||||
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',
|
||||
type: 'text',
|
||||
value: (row: User) => row.username,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
translationKey: 'user.email',
|
||||
type: 'text',
|
||||
value: (row: User) => row.email,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
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;
|
||||
if (!(emailsToInvite && emailsToInvite.length > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dataService
|
||||
.inviteUsers(emailsToInvite)
|
||||
.inviteUsers(id, emailsToInvite)
|
||||
.pipe(
|
||||
catchError(err => {
|
||||
this.spinner.hide();
|
||||
@ -102,7 +102,6 @@ export class UserSpaceFormPageComponent extends FormPageBase<
|
||||
}
|
||||
|
||||
create(object: UserSpaceCreateInput): void {
|
||||
this.handleEMailInvitation();
|
||||
this.dataService.create(object).subscribe(() => {
|
||||
this.spinner.hide();
|
||||
this.toast.success('action.created');
|
||||
@ -111,7 +110,7 @@ export class UserSpaceFormPageComponent extends FormPageBase<
|
||||
}
|
||||
|
||||
update(object: UserSpaceUpdateInput): void {
|
||||
this.handleEMailInvitation();
|
||||
this.handleEMailInvitation(object.id);
|
||||
this.dataService.update(object).subscribe(() => {
|
||||
this.spinner.hide();
|
||||
this.toast.success('action.created');
|
||||
|
@ -290,17 +290,18 @@ export class UserSpacesDataService
|
||||
.pipe(map(result => result.data?.userSpace.restore ?? false));
|
||||
}
|
||||
|
||||
inviteUsers(emails: string[]): Observable<boolean> {
|
||||
inviteUsers(id: number, emails: string[]): Observable<boolean> {
|
||||
return this.apollo
|
||||
.mutate<{ userSpace: { inviteUsers: boolean } }>({
|
||||
mutation: gql`
|
||||
mutation inviteUsers($emails: [String!]!) {
|
||||
mutation inviteUsers($id: Int!, $emails: [String!]!) {
|
||||
userSpace {
|
||||
inviteUsers(emails: $emails)
|
||||
inviteUsers(id: $id, emails: $emails)
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
emails,
|
||||
},
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user