22
33import secrets
44from collections .abc import AsyncGenerator , Callable
5- from dataclasses import asdict , dataclass , field
6- from datetime import datetime , timedelta
5+ from dataclasses import dataclass , field
6+ from datetime import UTC , datetime , timedelta
77from typing import Any , cast
88
99from sanic .log import logger
2828 DeletedUser ,
2929 KeycloakAdminEvent ,
3030 PinnedProjects ,
31+ UnsavedUserInfo ,
3132 UserInfo ,
3233 UserInfoFieldUpdate ,
3334 UserInfoUpdate ,
35+ UserPatch ,
3436 UserPreferences ,
3537)
3638from renku_data_services .users .orm import LastKeycloakEventTimestamp , UserORM , UserPreferencesORM
@@ -65,12 +67,12 @@ async def _add_api_user(self, user: APIUser) -> UserInfo:
6567 if not user .id :
6668 raise errors .UnauthorizedError (message = "The user has to be authenticated to be inserted in the DB." )
6769 result = await self ._users_sync .update_or_insert_user (
68- user_id = user .id ,
69- payload = dict (
70+ user = UnsavedUserInfo (
71+ id = user .id ,
72+ email = user .email ,
7073 first_name = user .first_name ,
7174 last_name = user .last_name ,
72- email = user .email ,
73- ),
75+ )
7476 )
7577 return result .new
7678
@@ -214,37 +216,44 @@ async def _get_user(self, id: str) -> UserInfo | None:
214216 @Authz .authz_change (AuthzOperation .update_or_insert , ResourceType .user )
215217 @dispatch_message (events .UpdateOrInsertUser )
216218 async def update_or_insert_user (
217- self , user_id : str , payload : dict [ str , Any ] , * , session : AsyncSession | None = None
219+ self , user : UnsavedUserInfo , * , session : AsyncSession | None = None
218220 ) -> UserInfoUpdate :
219221 """Update a user or insert it if it does not exist."""
220222 if not session :
221223 raise errors .ProgrammingError (message = "A database session is required" )
222- res = await session .execute (select (UserORM ).where (UserORM .keycloak_id == user_id ))
224+ res = await session .execute (select (UserORM ).where (UserORM .keycloak_id == user . id ))
223225 existing_user = res .scalar_one_or_none ()
224226 if existing_user :
225- return await self ._update_user (session = session , user_id = user_id , existing_user = existing_user , ** payload )
227+ return await self ._update_user (
228+ session = session ,
229+ user_id = user .id ,
230+ existing_user = existing_user ,
231+ patch = UserPatch .from_unsaved_user_info (user ),
232+ )
226233 else :
227- return await self ._insert_user (session = session , user_id = user_id , ** payload )
234+ return await self ._insert_user (session = session , user = user )
228235
229- async def _insert_user (self , session : AsyncSession , user_id : str , ** kwargs : Any ) -> UserInfoUpdate :
236+ async def _insert_user (self , session : AsyncSession , user : UnsavedUserInfo ) -> UserInfoUpdate :
230237 """Insert a user."""
231- kwargs .pop ("keycloak_id" , None )
232- kwargs .pop ("id" , None )
233- slug = base_models .Slug .from_user (
234- kwargs .get ("email" ), kwargs .get ("first_name" ), kwargs .get ("last_name" ), user_id
235- ).value
238+ slug = base_models .Slug .from_user (user .email , user .first_name , user .last_name , user .id ).value
236239 namespace = await self .group_repo ._create_user_namespace_slug (
237240 session , user_slug = slug , retry_enumerate = 5 , retry_random = True
238241 )
239242 slug = base_models .Slug .from_name (namespace )
240- new_user = UserORM (keycloak_id = user_id , namespace = NamespaceORM (slug = slug .value , user_id = user_id ), ** kwargs )
243+ new_user = UserORM (
244+ keycloak_id = user .id ,
245+ namespace = NamespaceORM (slug = slug .value , user_id = user .id ),
246+ email = user .email ,
247+ first_name = user .first_name ,
248+ last_name = user .last_name ,
249+ )
241250 new_user .namespace .user = new_user
242251 session .add (new_user )
243252 await session .flush ()
244253 return UserInfoUpdate (None , new_user .dump ())
245254
246255 async def _update_user (
247- self , session : AsyncSession , user_id : str , existing_user : UserORM | None , ** kwargs : Any
256+ self , session : AsyncSession , user_id : str , existing_user : UserORM | None , patch : UserPatch
248257 ) -> UserInfoUpdate :
249258 """Update a user."""
250259 if not existing_user :
@@ -254,13 +263,13 @@ async def _update_user(
254263 if not existing_user :
255264 raise errors .MissingResourceError (message = f"The user with id '{ user_id } ' cannot be found" )
256265 old_user = existing_user .dump ()
257-
258- kwargs .pop ("keycloak_id" , None )
259- kwargs .pop ("id" , None )
260266 session .add (existing_user ) # reattach to session
261- for field_name , field_value in kwargs .items ():
262- if getattr (existing_user , field_name , None ) != field_value :
263- setattr (existing_user , field_name , field_value )
267+ if patch .email is not None :
268+ existing_user .email = patch .email if patch .email else None
269+ if patch .first_name is not None :
270+ existing_user .first_name = patch .first_name if patch .first_name else None
271+ if patch .last_name is not None :
272+ existing_user .last_name = patch .last_name if patch .last_name else None
264273 namespace = await self .group_repo .get_user_namespace (user_id )
265274 if not namespace :
266275 raise errors .ProgrammingError (
@@ -279,7 +288,7 @@ async def _do_update(raw_kc_user: dict[str, Any]) -> None:
279288 db_user = await self ._get_user (kc_user .id )
280289 if db_user != kc_user :
281290 logger .info (f"Inserting or updating user { db_user } -> { kc_user } " )
282- await self .update_or_insert_user (kc_user . id , asdict ( kc_user ) )
291+ await self .update_or_insert_user (user = kc_user )
283292
284293 # NOTE: If asyncio.gather is used here you quickly exhaust all DB connections
285294 # or timeout on waiting for available connections
@@ -300,7 +309,7 @@ async def events_sync(self, kc_api: IKeycloakAPI) -> None:
300309 latest_utc_timestamp_orm .timestamp_utc if latest_utc_timestamp_orm is not None else None
301310 )
302311 logger .info (f"The previous sync latest event is { previous_sync_latest_utc_timestamp } UTC" )
303- now_utc = datetime .utcnow ( )
312+ now_utc = datetime .now ( tz = UTC )
304313 start_date = now_utc .date () - timedelta (days = 1 )
305314 logger .info (f"Pulling events with a start date of { start_date } UTC" )
306315 user_events = kc_api .get_user_events (start_date = start_date )
@@ -324,7 +333,10 @@ async def events_sync(self, kc_api: IKeycloakAPI) -> None:
324333 latest_delete_timestamp = None
325334 for update in parsed_updates :
326335 logger .info (f"Processing update event { update } " )
327- await self .update_or_insert_user (update .user_id , {update .field_name : update .new_value })
336+ # TODO: add typing to `update.field_name` for safer updates
337+ await self .update_or_insert_user (
338+ user = UnsavedUserInfo (id = update .user_id , ** {update .field_name : update .new_value })
339+ )
328340 latest_update_timestamp = update .timestamp_utc
329341 for deletion in parsed_deletions :
330342 logger .info (f"Processing deletion event { deletion } " )
0 commit comments