22import uuid
33from typing import Annotated
44
5+ import pandas as pd
56import 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
710from sqlalchemy .ext .asyncio import AsyncSession
811
912from app import models , schemas
1215from app .db import repos as repo
1316from app .schemas import UserInvite
1417from app .utils .emails import send_registration_email
15- from app .utils .exceptions import UserAlreadyActiveException
18+ from app .utils .exceptions import InvalidFileTypeException , UserAlreadyActiveException
1619from app .utils .mocks import mock_worker_job
1720
1821router = 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+
74142async 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
0 commit comments