aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorstuppie2026-08-28 14:25:05 -0600
committerstuppie2026-08-28 14:25:05 -0600
commit7a2d647577fd62db042a1ec72a8a0696e7552ad4 (patch)
tree1a9f6ae745374f57600e35f0789f2653d8dbf583
parentb6cc7b23f13cfef41106d1db59533bc179d80cc9 (diff)
downloadgeneralresearch-7a2d647577fd62db042a1ec72a8a0696e7552ad4.tar.gz
generalresearch-7a2d647577fd62db042a1ec72a8a0696e7552ad4.zip
Get leaderboard to fill in users' display names
-rw-r--r--generalresearch/managers/leaderboard/manager.py73
-rw-r--r--generalresearch/managers/thl/user_manager/user_metadata_manager.py18
-rw-r--r--generalresearch/models/thl/leaderboard.py4
-rw-r--r--generalresearch/models/thl/user.py6
-rw-r--r--generalresearch/models/thl/user_profile.py6
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(