diff options
| author | stuppie | 2026-08-28 14:25:05 -0600 |
|---|---|---|
| committer | stuppie | 2026-08-28 14:25:05 -0600 |
| commit | 7a2d647577fd62db042a1ec72a8a0696e7552ad4 (patch) | |
| tree | 1a9f6ae745374f57600e35f0789f2653d8dbf583 | |
| parent | b6cc7b23f13cfef41106d1db59533bc179d80cc9 (diff) | |
| download | generalresearch-7a2d647577fd62db042a1ec72a8a0696e7552ad4.tar.gz generalresearch-7a2d647577fd62db042a1ec72a8a0696e7552ad4.zip | |
Get leaderboard to fill in users' display names
| -rw-r--r-- | generalresearch/managers/leaderboard/manager.py | 73 | ||||
| -rw-r--r-- | generalresearch/managers/thl/user_manager/user_metadata_manager.py | 18 | ||||
| -rw-r--r-- | generalresearch/models/thl/leaderboard.py | 4 | ||||
| -rw-r--r-- | generalresearch/models/thl/user.py | 6 | ||||
| -rw-r--r-- | generalresearch/models/thl/user_profile.py | 6 |
5 files changed, 74 insertions, 33 deletions
diff --git a/generalresearch/managers/leaderboard/manager.py b/generalresearch/managers/leaderboard/manager.py index 0bf0312..c44a461 100644 --- a/generalresearch/managers/leaderboard/manager.py +++ b/generalresearch/managers/leaderboard/manager.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone from decimal import Decimal from functools import cached_property -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import pandas as pd from pandas import Period @@ -11,6 +11,9 @@ from pydantic import AwareDatetime, NaiveDatetime from redis import Redis from generalresearch.managers.leaderboard import country_timezone +from generalresearch.managers.thl.user_manager.user_metadata_manager import ( + UserMetadataManager, +) from generalresearch.models.thl.leaderboard import ( Leaderboard, LeaderboardCode, @@ -43,7 +46,6 @@ class LeaderboardManager: self.freq = freq self.product_id = product_id self.country_iso = country_iso - self.within_time_aware = None if within_time is None: self.within_time_aware = datetime.now(tz=timezone.utc).astimezone( self.timezone @@ -85,28 +87,37 @@ class LeaderboardManager: def get_row_count(self) -> int: # How many rows (unique users) does this leaderboard have? - return self.redis_client.zcard(self.key) or 0 + # redis-py types command responses broadly, but ZCARD returns an integer. + return cast(int, self.redis_client.zcard(self.key)) def get_leaderboard_rows( - self, - limit: int | None = None, + self, user_metadata_manager: UserMetadataManager, limit: int | None = None ) -> list[LeaderboardRow]: - limit = limit if limit else 0 + limit = limit or 0 res = self.redis_client.zrange( self.key, start=0, end=limit - 1, withscores=True, desc=True ) # We re-rank using pandas min value for ties. Redis does not consider ties in ranking. - s = pd.DataFrame(res, columns=["bpuid", "value"]).sort_values( + s = pd.DataFrame(res, columns=pd.Index(["bpuid", "value"])).sort_values( by="value", ascending=False ) s["rank"] = s["value"].rank(method="min", ascending=False) - return [ - LeaderboardRow(bpuid=r.bpuid, value=r.value, rank=r.rank) - for r in s.itertuples() + rows = [ + LeaderboardRow(bpuid=bpuid, value=value, rank=rank) + for bpuid, value, rank in s.itertuples(index=False, name=None) ] + um = user_metadata_manager.filter_by_bpuids( + product_id=self.product_id, product_user_ids={r.bpuid for r in rows} + ) + for row in rows: + row.display_name = um[row.bpuid].display_name + return rows def get_personal_leaderboard_rows( - self, bp_user_id: str, limit: int | None = 5 + self, + user_metadata_manager: UserMetadataManager, + bp_user_id: str, + limit: int | None = 5, ) -> list[LeaderboardRow]: # We can't just grab this user's rank and nearby rows b/c redis does # not handle ties the same way we do (in redis, each value is a @@ -123,7 +134,9 @@ class LeaderboardManager: user_idx = user_indices[0][0] user_row = user_indices[0][1] if user_row.rank == max([row.rank for row in rows]): - user_idx = [i for i, row in enumerate(rows) if row.rank == user_row.rank][0] + user_idx = next( + i for i, row in enumerate(rows) if row.rank == user_row.rank + ) start: int = max(user_idx - limit, 0) end: int = min(user_idx + limit + 1, len(rows)) @@ -131,17 +144,20 @@ class LeaderboardManager: def get_leaderboard( self, + user_metadata_manager: UserMetadataManager, limit: int | None = None, bp_user_id: str | None = None, ) -> Leaderboard: if bp_user_id: rows = self.get_personal_leaderboard_rows( - bp_user_id=bp_user_id, limit=limit + bp_user_id=bp_user_id, + limit=limit, + user_metadata_manager=user_metadata_manager, ) else: rows = self.get_leaderboard_rows( - limit=limit, + limit=limit, user_metadata_manager=user_metadata_manager ) total = self.get_row_count() @@ -164,25 +180,25 @@ class LeaderboardManager: ) def hit_complete_count(self, product_user_id: str) -> None: - assert ( - self.board_code == LeaderboardCode.COMPLETE_COUNT - ), "wrong kind of leaderboard" + assert self.board_code == LeaderboardCode.COMPLETE_COUNT, ( + "wrong kind of leaderboard" + ) self.redis_client.zincrby(self.key, amount=1, value=product_user_id) self.redis_client.expire(self.key, time=self.expiration) def hit_sum_payouts(self, product_user_id: str, user_payout: Decimal) -> None: - assert ( - self.board_code == LeaderboardCode.SUM_PAYOUTS - ), "wrong kind of leaderboard" + assert self.board_code == LeaderboardCode.SUM_PAYOUTS, ( + "wrong kind of leaderboard" + ) self.redis_client.zincrby( self.key, amount=round(user_payout * 100), value=product_user_id ) self.redis_client.expire(self.key, time=self.expiration) def hit_largest_payout(self, product_user_id: str, user_payout: Decimal) -> None: - assert ( - self.board_code == LeaderboardCode.LARGEST_PAYOUT - ), "wrong kind of leaderboard" + assert self.board_code == LeaderboardCode.LARGEST_PAYOUT, ( + "wrong kind of leaderboard" + ) # Only sets the value if the new value is greater than the existing self.redis_client.zadd( self.key, {product_user_id: round(user_payout * 100)}, gt=True @@ -191,18 +207,19 @@ class LeaderboardManager: def hit(self, session: Session) -> None: user = session.user + assert user.product_user_id is not None match self.board_code: case LeaderboardCode.COMPLETE_COUNT: - return self.hit_complete_count(product_user_id=user.product_user_id) + self.hit_complete_count(product_user_id=user.product_user_id) case LeaderboardCode.SUM_PAYOUTS: - return self.hit_sum_payouts( + assert session.user_payout is not None + self.hit_sum_payouts( product_user_id=user.product_user_id, user_payout=session.user_payout, ) case LeaderboardCode.LARGEST_PAYOUT: - return self.hit_largest_payout( + assert session.user_payout is not None + self.hit_largest_payout( product_user_id=user.product_user_id, user_payout=session.user_payout, ) - - return None diff --git a/generalresearch/managers/thl/user_manager/user_metadata_manager.py b/generalresearch/managers/thl/user_manager/user_metadata_manager.py index 4f394f1..91ac72e 100644 --- a/generalresearch/managers/thl/user_manager/user_metadata_manager.py +++ b/generalresearch/managers/thl/user_manager/user_metadata_manager.py @@ -7,6 +7,24 @@ from generalresearch.models.thl.user_profile import UserMetadata class UserMetadataManager(PostgresManager): + def filter_by_bpuids( + self, product_id: str, product_user_ids: Collection[str] + ) -> dict[str, UserMetadata]: + query = """ + SELECT email_address, email_sha256, email_sha1, email_md5, display_name, + product_user_id, thl_user.id as user_id + FROM thl_usermetadata + RIGHT OUTER JOIN thl_user on thl_usermetadata.user_id = thl_user.id + WHERE product_id = %(product_id)s + AND product_user_id = ANY(%(product_user_ids)s); + """ + res = self.pg_config.execute_sql_query( + query, + {"product_id": product_id, "product_user_ids": list(product_user_ids)}, + ) + + return {x["product_user_id"]: UserMetadata.from_db(**x) for x in res} + def filter( self, user_ids: Collection[int] | None = None, diff --git a/generalresearch/models/thl/leaderboard.py b/generalresearch/models/thl/leaderboard.py index bc55034..d73c7cf 100644 --- a/generalresearch/models/thl/leaderboard.py +++ b/generalresearch/models/thl/leaderboard.py @@ -67,6 +67,10 @@ class LeaderboardRow(BaseModel): examples=[7], ) + display_name: str | None = Field( + description="Optional public name chosen by the user", default=None + ) + def censor(self): censor_idx = math.ceil(len(self.bpuid) / 2) self.bpuid = self.bpuid[:censor_idx] + ("*" * len(self.bpuid[censor_idx:])) diff --git a/generalresearch/models/thl/user.py b/generalresearch/models/thl/user.py index 355e331..08e59a0 100644 --- a/generalresearch/models/thl/user.py +++ b/generalresearch/models/thl/user.py @@ -4,7 +4,7 @@ import json import logging import re from datetime import datetime, timezone -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Annotated from uuid import UUID, uuid4 from pydantic import ( @@ -19,7 +19,7 @@ from pydantic import ( model_validator, ) from sentry_sdk import set_tag, set_user -from typing_extensions import Annotated, Self +from typing_extensions import Self from generalresearch.models import MAX_INT32 from generalresearch.models.custom_types import AwareDatetimeISO, UUIDStr @@ -35,8 +35,6 @@ if TYPE_CHECKING: ) from generalresearch.managers.thl.userhealth import AuditLogManager - # from generalresearch.managers.thl.userhealth import UserIpHistoryManager - logger = logging.getLogger() BPUID_ALLOWED = r"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#$%&()*+,-.:;<=>?@[\]^_{|}~" diff --git a/generalresearch/models/thl/user_profile.py b/generalresearch/models/thl/user_profile.py index 064036f..13e8af1 100644 --- a/generalresearch/models/thl/user_profile.py +++ b/generalresearch/models/thl/user_profile.py @@ -27,7 +27,11 @@ class UserMetadata(BaseModel): email_address: EmailStr | None = Field(default=None, examples=["contact@mail.com"]) - display_name: str | None = Field(default=None, max_length=255) + display_name: str | None = Field( + default=None, + max_length=255, + description="A public name chosen by the user. Can be used in leaderboards or event stream.", + ) @computed_field def email_md5( |
