diff options
| author | stuppie | 2026-08-28 15:58:42 -0600 |
|---|---|---|
| committer | stuppie | 2026-08-28 15:58:42 -0600 |
| commit | f7b187e553bea8f8a393d5c67c607fa667de1925 (patch) | |
| tree | 65295b8ce496f5d1f48e0a22f2a539a503a1c4af | |
| parent | 7a2d647577fd62db042a1ec72a8a0696e7552ad4 (diff) | |
| download | generalresearch-f7b187e553bea8f8a393d5c67c607fa667de1925.tar.gz generalresearch-f7b187e553bea8f8a393d5c67c607fa667de1925.zip | |
add user manager change_product_user_id
3 files changed, 91 insertions, 30 deletions
diff --git a/generalresearch/managers/thl/user_manager/mysql_user_manager.py b/generalresearch/managers/thl/user_manager/mysql_user_manager.py index 22abaa8..109b601 100644 --- a/generalresearch/managers/thl/user_manager/mysql_user_manager.py +++ b/generalresearch/managers/thl/user_manager/mysql_user_manager.py @@ -40,6 +40,42 @@ class MysqlUserManager: params=[now, user.user_id], ) + def _change_product_user_id( + self, *, user: User, new_product_user_id: str + ) -> User: + """Change a user's supplier-provided ID in the primary database.""" + assert not self.is_read_replica + assert user.user_id is not None + assert user.product_id is not None + assert user.product_user_id is not None + + with self.pg_config.make_connection() as conn: + with conn.cursor() as c: + c.execute( + query=""" + UPDATE thl_user + SET product_user_id = %(new_product_user_id)s + WHERE id = %(user_id)s + AND product_id = %(product_id)s + AND product_user_id = %(old_product_user_id)s + RETURNING id AS user_id, product_id, product_user_id, + uuid, blocked, created, last_seen + """, + params={ + "new_product_user_id": new_product_user_id, + "user_id": user.user_id, + "product_id": user.product_id, + "old_product_user_id": user.product_user_id, + }, + ) + row = c.fetchone() + + if row is None: + raise RuntimeError( + "User was not updated; it may have been changed concurrently" + ) + return User.from_db(row) + def get_user_from_mysql( self, *, diff --git a/generalresearch/managers/thl/user_manager/redis_user_manager.py b/generalresearch/managers/thl/user_manager/redis_user_manager.py index 2190e6e..730cb9e 100644 --- a/generalresearch/managers/thl/user_manager/redis_user_manager.py +++ b/generalresearch/managers/thl/user_manager/redis_user_manager.py @@ -76,7 +76,6 @@ class RedisUserManager: p.execute() def clear_user(self, user: User) -> None: - # this should only be used by tests with self.client.pipeline(transaction=False) as p: p.delete(f"{self.cache_prefix}:uuid:{user.uuid}") p.delete(f"{self.cache_prefix}:user_id:{user.user_id}") diff --git a/generalresearch/managers/thl/user_manager/user_manager.py b/generalresearch/managers/thl/user_manager/user_manager.py index 5da3370..7f24029 100644 --- a/generalresearch/managers/thl/user_manager/user_manager.py +++ b/generalresearch/managers/thl/user_manager/user_manager.py @@ -35,9 +35,9 @@ auditlog = logging.getLogger("auditlog") class UserManager: def __init__( self, - redis: RedisDsn | None = None, - pg_config: PostgresConfig | None = None, - pg_config_rr: PostgresConfig | None = None, + redis: RedisDsn, + pg_config: PostgresConfig, + pg_config_rr: PostgresConfig, sql_permissions: Collection[Permission] | None = None, cache_prefix: str | None = None, redis_timeout: float | None = None, @@ -47,9 +47,9 @@ class UserManager: sql_permissions = [] if pg_config is not None: - assert ( - pg_config_rr is not None - ), "you should pass RR credentials also for fast lookups" + assert pg_config_rr is not None, ( + "you should pass RR credentials also for fast lookups" + ) assert Permission.DELETE not in sql_permissions, "delete not allowed" if Permission.UPDATE in sql_permissions or Permission.CREATE in sql_permissions: @@ -87,6 +87,33 @@ class UserManager: assert Permission.UPDATE in self.sql_permissions, "permission error" return self.mysql_user_manager._set_last_seen(user) + def change_product_user_id(self, *, user: User, new_product_user_id: str) -> User: + """Change a user's supplier-provided ID and refresh user lookup caches. + + This does not rewrite historical or derived data that copied the old ID, + such as leaderboards and activity counters. + """ + assert Permission.UPDATE in self.sql_permissions, "permission error" + assert self.mysql_user_manager is not None + assert user.product_id is not None + assert user.product_user_id is not None + + if new_product_user_id == user.product_user_id: + return user + if not User.is_valid_ubp( + product_id=user.product_id, product_user_id=new_product_user_id + ): + raise ValueError("invalid product_id/product_user_id") + + updated_user = self.mysql_user_manager._change_product_user_id( + user=user, new_product_user_id=new_product_user_id + ) + + self.cache_clear() + if self.redis_user_manager: + self.redis_user_manager.clear_user(user) + return updated_user + def audit_log( self, user: User, @@ -99,6 +126,7 @@ class UserManager: from generalresearch.models.thl.userhealth import AuditLogLevel alm = AuditLogManager(pg_config=self.mysql_user_manager.pg_config) + assert user.user_id is not None alm.create( user_id=user.user_id, level=AuditLogLevel(level), @@ -107,8 +135,6 @@ class UserManager: event_value=event_value, ) - return None - def cache_clear(self) -> None: # Generally this is used in testing. This clears get_user's TTL cache. # It does not clear any redis caches; that has to be done separately. @@ -134,16 +160,16 @@ class UserManager: Raises UserDoesntExistError if user is not found. (the * makes all arguments keyword-only arguments) """ - assert ( - (product_id and product_user_id) or user_id or user_uuid - ), "Must pass either (product_id, product_user_id), or user_id, or uuid" + assert (product_id and product_user_id) or user_id or user_uuid, ( + "Must pass either (product_id, product_user_id), or user_id, or uuid" + ) if product_id or product_user_id: - assert ( - product_id and product_user_id - ), "Must pass both product_id and product_user_id" - assert ( - sum(map(bool, [product_id or product_id, user_id, user_uuid])) == 1 - ), "Must pass only 1 of (product_id, product_user_id), or user_id, or uuid" + assert product_id and product_user_id, ( + "Must pass both product_id and product_user_id" + ) + assert sum(map(bool, [product_id or product_id, user_id, user_uuid])) == 1, ( + "Must pass only 1 of (product_id, product_user_id), or user_id, or uuid" + ) user = self.get_user_inmemory_cache( product_id=product_id, product_user_id=product_user_id, @@ -245,13 +271,13 @@ class UserManager: """ assert Permission.CREATE in self.sql_permissions assert self.mysql_user_manager is not None - assert ( - self.redis_user_manager is not None - ), "need at least redis to synchronize user creation" + assert self.redis_user_manager is not None, ( + "need at least redis to synchronize user creation" + ) - assert ( - self.user_manager_limiter is not None - ), "Need user_manager_limiter to get_or_create_user" + assert self.user_manager_limiter is not None, ( + "Need user_manager_limiter to get_or_create_user" + ) # Attempt to create common_struct solely for validation purposes if not User.is_valid_ubp( product_id=product_id, product_user_id=product_user_id @@ -275,9 +301,9 @@ class UserManager: created: datetime | None = None, ) -> User: - assert ( - self.user_manager_limiter is not None - ), "Need user_manager_limiter to create_user" + assert self.user_manager_limiter is not None, ( + "Need user_manager_limiter to create_user" + ) assert product_id or product, "Needs a product_id or a Product instance" if product is None: @@ -354,9 +380,9 @@ class UserManager: user_ids: Collection[int] | None = None, user_uuids: Collection[str] | None = None, ) -> list[User]: - assert (user_ids or user_uuids) and not ( - user_ids and user_uuids - ), "Must pass ONE of user_ids, user_uuids" + assert (user_ids or user_uuids) and not (user_ids and user_uuids), ( + "Must pass ONE of user_ids, user_uuids" + ) return self.mysql_user_manager_rr.fetch( user_ids=user_ids, user_uuids=user_uuids ) |
