Skip to content

Commit f1e525a

Browse files
authored
refactor: use typed patch instance for user updates (#497)
See #485.
1 parent 0cb8a17 commit f1e525a

2 files changed

Lines changed: 57 additions & 27 deletions

File tree

components/renku_data_services/users/db.py

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
import secrets
44
from 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
77
from typing import Any, cast
88

99
from sanic.log import logger
@@ -28,9 +28,11 @@
2828
DeletedUser,
2929
KeycloakAdminEvent,
3030
PinnedProjects,
31+
UnsavedUserInfo,
3132
UserInfo,
3233
UserInfoFieldUpdate,
3334
UserInfoUpdate,
35+
UserPatch,
3436
UserPreferences,
3537
)
3638
from 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}")

components/renku_data_services/users/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,24 @@ class UserInfo(UnsavedUserInfo):
260260
namespace: Namespace
261261

262262

263+
@dataclass(frozen=True, eq=True, kw_only=True)
264+
class UserPatch:
265+
"""Model for changes requested on a user."""
266+
267+
first_name: str | None = None
268+
last_name: str | None = None
269+
email: str | None = None
270+
271+
@classmethod
272+
def from_unsaved_user_info(cls, user: UnsavedUserInfo) -> "UserPatch":
273+
"""Create a user patch from a UnsavedUserInfo instance."""
274+
return UserPatch(
275+
first_name=user.first_name,
276+
last_name=user.last_name,
277+
email=user.email,
278+
)
279+
280+
263281
@dataclass
264282
class DeletedUser:
265283
"""A user that was deleted from the database."""

0 commit comments

Comments
 (0)