Skip to content

Commit d7636e0

Browse files
author
ahmedabdou14
committed
Create bulk invite api
1 parent 7402a6a commit d7636e0

2 files changed

Lines changed: 150 additions & 4 deletions

File tree

app/api/routes/v1/invite.py

Lines changed: 141 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
import uuid
33
from typing import Annotated
44

5+
import pandas as pd
56
import rq
6-
from fastapi import APIRouter, Body, Query, status
7+
from fastapi import APIRouter, Body, File, HTTPException, Query, UploadFile, status
8+
from pydantic import validate_email
9+
from pydantic_core import PydanticCustomError
710
from sqlalchemy.ext.asyncio import AsyncSession
811

912
from app import models, schemas
@@ -12,7 +15,7 @@
1215
from app.db import repos as repo
1316
from app.schemas import UserInvite
1417
from app.utils.emails import send_registration_email
15-
from app.utils.exceptions import UserAlreadyActiveException
18+
from app.utils.exceptions import InvalidFileTypeException, UserAlreadyActiveException
1619
from app.utils.mocks import mock_worker_job
1720

1821
router = APIRouter()
@@ -71,6 +74,71 @@ async def invite_user(
7174
)
7275

7376

77+
@router.post(
78+
"/bulk",
79+
status_code=status.HTTP_202_ACCEPTED,
80+
)
81+
async def bulk_invite_users(
82+
db: DbDep,
83+
mq: MQDefault,
84+
admin: AdminDep,
85+
are_admin: Annotated[bool, Query(description="Are the invitees admins?")],
86+
sheet: Annotated[
87+
UploadFile,
88+
File(
89+
...,
90+
description="""
91+
CSV file of invitees emails
92+
emails need to be in the first column of the first sheet
93+
""",
94+
),
95+
],
96+
expires_in_hours: Annotated[
97+
int,
98+
Query(
99+
gt=0,
100+
alias="expires_in",
101+
description="hours",
102+
),
103+
] = 7 * 24,
104+
):
105+
"""
106+
## Invite multiple users to join Vaultexe server
107+
108+
## Permissions
109+
* Inviter is an admin
110+
111+
## Prerequisites
112+
* The invitees must not be active yet (i.e. never registered before)
113+
114+
## Notes
115+
* File must be an excel file
116+
* A new inactive user will be created for each invitee
117+
* Each invitee will receive an email with a link to activate their account
118+
* The invitation will expire after 7 days (default)
119+
* All previous invitations to the invitees will be invalidated
120+
"""
121+
emails = read_validated_emails(sheet)
122+
123+
user_invites = [UserInvite(email=email, is_admin=are_admin) for email in emails]
124+
125+
invitees = await repo.user.bulk_create(db, objs_in=user_invites)
126+
await db.commit()
127+
for invitee in invitees:
128+
await db.refresh(invitee)
129+
130+
invitees = [invitee for invitee in invitees if not invitee.is_active]
131+
await repo.invitation.invalidate_bulk_tokens(db, user_ids=[inv.id for inv in invitees])
132+
133+
await setup_bulk_inviations(
134+
mq=mq,
135+
db=db,
136+
admin=admin,
137+
invitees=invitees,
138+
expires_in_hours=expires_in_hours,
139+
)
140+
141+
74142
async def setup_inviation(
75143
*,
76144
db: AsyncSession,
@@ -81,7 +149,6 @@ async def setup_inviation(
81149
) -> schemas.WorkerJob:
82150
"""Handle invitation tokens & invitation email"""
83151
invitation_token = uuid.uuid4()
84-
85152
expires_at = dt.datetime.now(dt.UTC) + dt.timedelta(hours=expires_in_hours)
86153

87154
new_invitation = schemas.InvitationCreate(
@@ -92,7 +159,6 @@ async def setup_inviation(
92159
)
93160

94161
await repo.invitation.create(db, obj_in=new_invitation)
95-
96162
await db.commit()
97163

98164
if not settings.email_enabled:
@@ -112,3 +178,74 @@ async def setup_inviation(
112178
)
113179

114180
return schemas.WorkerJob.from_rq_job(job)
181+
182+
183+
async def setup_bulk_inviations(
184+
*,
185+
db: AsyncSession,
186+
mq: rq.Queue,
187+
admin: models.User,
188+
invitees: list[models.User],
189+
expires_in_hours: int,
190+
) -> None:
191+
"""Handle invitation tokens & invitation emails"""
192+
invitation_tokens = [uuid.uuid4() for _ in range(len(invitees))]
193+
expires_at = dt.datetime.now(dt.UTC) + dt.timedelta(hours=expires_in_hours)
194+
195+
new_invitations = [
196+
schemas.InvitationCreate(
197+
token=token,
198+
invitee_id=invitee.id,
199+
created_by=admin.id,
200+
expires_at=expires_at,
201+
)
202+
for token, invitee in zip(invitation_tokens, invitees, strict=True)
203+
]
204+
205+
await repo.invitation.bulk_create(db, objs_in=new_invitations)
206+
await db.commit()
207+
208+
if not settings.email_enabled:
209+
return
210+
211+
email_payloads = [
212+
schemas.RegistrationEmailPayload(
213+
to=invitee.email,
214+
token=token.hex,
215+
expires_in_hours=expires_in_hours,
216+
)
217+
for token, invitee in zip(invitation_tokens, invitees, strict=True)
218+
]
219+
220+
mq.enqueue_many(
221+
[
222+
rq.Queue.prepare_data(
223+
func=send_registration_email,
224+
args=(payload,),
225+
retry=rq.Retry(max=2),
226+
result_ttl=settings.EMAILS_STATUS_TTL,
227+
)
228+
for payload in email_payloads
229+
]
230+
)
231+
232+
233+
234+
def read_validated_emails(sheet: UploadFile) -> list[str]:
235+
try:
236+
content = pd.read_excel(sheet.file, header=None).values.flatten().tolist()
237+
except Exception:
238+
raise InvalidFileTypeException("excel")
239+
return get_validate_emails(content)
240+
241+
242+
def get_validate_emails(emails: list[str]) -> list[str]:
243+
for i in range(len(emails)):
244+
try:
245+
_, emails[i] = validate_email(emails[i])
246+
except PydanticCustomError:
247+
raise HTTPException(
248+
status_code=status.HTTP_400_BAD_REQUEST,
249+
detail=f"Invalid email at line {i}: {emails[i]}",
250+
)
251+
return emails

app/utils/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,12 @@ class DuplicateEntityException(HTTPException):
9292
def __init__(self, model: type[BaseModel] | str) -> None:
9393
entity = model if isinstance(model, str) else capitalize_first_letter(model.table_name())
9494
super().__init__(status_code=status.HTTP_409_CONFLICT, detail=f"{entity} already exists")
95+
96+
97+
class InvalidFileTypeException(HTTPException):
98+
def __init__(self, type: str | None = None) -> None:
99+
expected_statement = f"Expected {type} file" if type else ""
100+
super().__init__(
101+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
102+
detail=f"Invalid file type\n{expected_statement}",
103+
)

0 commit comments

Comments
 (0)