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

This commit is contained in:
Sven Heidemann 2025-05-01 21:03:40 +02:00
parent b815710dd6
commit 3f7a398392
15 changed files with 208 additions and 16 deletions

View 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)

View File

@ -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 {

View File

@ -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):

View File

@ -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)

View File

@ -64,7 +64,7 @@ class Subscription(SubscriptionABC):
self.subscribe(
SubscriptionFieldBuilder("userSpaceChange").with_resolver(
lambda message, *_: message.message
)
).with_public(True)
)
self.subscribe(
SubscriptionFieldBuilder("groupChange")

View 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)

View 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()

View File

@ -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'}
"""
)

View File

@ -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

View File

@ -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})

View File

@ -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;

View File

@ -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();

View File

@ -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',

View File

@ -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');

View File

@ -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,
},
})