From c59786fc91bcf674e0352704cb23031f79ca901c Mon Sep 17 00:00:00 2001 From: stuppie Date: Mon, 26 Oct 2026 12:58:14 -0600 Subject: user manager: add set_last_activity method for last_X timestamps --- .../thl/user_manager/mysql_user_manager.py | 34 +++++++++++++++------- .../managers/thl/user_manager/user_manager.py | 13 +++++++-- generalresearch/models/thl/user.py | 18 ++++++++++-- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/generalresearch/managers/thl/user_manager/mysql_user_manager.py b/generalresearch/managers/thl/user_manager/mysql_user_manager.py index 6b72edd..920cd22 100644 --- a/generalresearch/managers/thl/user_manager/mysql_user_manager.py +++ b/generalresearch/managers/thl/user_manager/mysql_user_manager.py @@ -4,8 +4,9 @@ import logging import operator from collections.abc import Collection from datetime import UTC, datetime +from enum import StrEnum from threading import Lock -from typing import TYPE_CHECKING, Any, Tuple +from typing import TYPE_CHECKING, Any from uuid import uuid4 import psycopg @@ -25,6 +26,13 @@ logger = logging.getLogger() logger.setLevel(logging.INFO) +class LastActivityColumn(StrEnum): + LAST_SEEN = "last_seen" + LAST_TASK_COMPLETED_AT = "last_task_completed_at" + LAST_OFFERWALL_REQUESTED_AT = "last_offerwall_requested_at" + LAST_PROFILING_SUBMISSION_AT = "last_profiling_submission_at" + + class MysqlUserManager(PostgresManager): def __init__( self, @@ -37,18 +45,22 @@ class MysqlUserManager(PostgresManager): self.product_id_exists_cache_lock = Lock() super().__init__(pg_config=pg_config, permissions=permissions) - def _set_last_seen(self, user: User, conn: Connection | None = None) -> None: - # Don't call this directly. Use UserManager.set_last_seen() + def _set_last_activity( + self, + user: User, + column: LastActivityColumn, + conn: Connection | None = None, + ) -> None: + # Don't call this directly. Use UserManager.set_last_activity(). assert not self.is_read_replica - now = datetime.now(tz=UTC) + column = LastActivityColumn(column) + with self.connection(conn) as conn, conn.cursor() as c: c.execute( - """ - UPDATE thl_user - SET last_seen = %s - WHERE id = %s - """, - params=[now, user.user_id], + sql.SQL("UPDATE thl_user SET {} = %s WHERE id = %s").format( + sql.Identifier(column) + ), + params=[getattr(user, column), user.user_id], ) def _set_last_country_ip( @@ -535,7 +547,7 @@ class MysqlUserManager(PostgresManager): product_user_id=product_user_id, email_startswith=email_startswith, display_name_like=display_name_like, - allow_global_search=allow_global_search + allow_global_search=allow_global_search, ) if not any([product_user_id, email_startswith, display_name_like]): # We're filtering on product, team, or business only. So use the diff --git a/generalresearch/managers/thl/user_manager/user_manager.py b/generalresearch/managers/thl/user_manager/user_manager.py index 6358e84..baa7c1f 100644 --- a/generalresearch/managers/thl/user_manager/user_manager.py +++ b/generalresearch/managers/thl/user_manager/user_manager.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import operator from collections.abc import Collection -from datetime import datetime +from datetime import UTC, datetime from threading import Lock from typing import TYPE_CHECKING @@ -17,6 +17,7 @@ from generalresearch.managers.thl.user_manager.exceptions import ( UserDoesntExistError, ) from generalresearch.managers.thl.user_manager.mysql_user_manager import ( + LastActivityColumn, MysqlUserManager, ) from generalresearch.managers.thl.user_manager.rate_limit import ( @@ -92,8 +93,16 @@ class UserManager: self.get_user_cache_lock = Lock() def set_last_seen(self, user: User) -> None: + return self.set_last_activity(user, LastActivityColumn.LAST_SEEN) + + def set_last_activity(self, user: User, column: LastActivityColumn) -> None: assert Permission.UPDATE in self.sql_permissions, "permission error" - return self.mysql_user_manager._set_last_seen(user) + assert self.redis_user_manager is not None + column = LastActivityColumn(column) + key = f"user-activity:{user.user_id}:{column.value}" + if self.redis_user_manager.client.set(key, "1", nx=True, ex=60): + setattr(user, column, datetime.now(tz=UTC)) + self.mysql_user_manager._set_last_activity(user, column) def set_last_country_ip( self, user: User, last_ip: str, last_country_iso: str | None = None diff --git a/generalresearch/models/thl/user.py b/generalresearch/models/thl/user.py index 59a14db..ba49104 100644 --- a/generalresearch/models/thl/user.py +++ b/generalresearch/models/thl/user.py @@ -77,11 +77,23 @@ class User(BaseModel): description="When the user was created on the GRL platform.", ) - # Note: due to cacheing, last_seen might be up to a day out of date! + # Note: due to cacheing, last_* timestamps might be up to a day out of date! + # Updates are rate-limited to once per minute last_seen: AwareDatetimeISO | None = Field( default=None, - description="When the user was last seen on, or acting on any" - "part of the GRL platform.", + description="When the user last started a task or returned from a marketplace redirect.", + ) + last_task_completed_at: AwareDatetimeISO | None = Field( + default=None, + description="When the user most recently completed a task.", + ) + last_offerwall_requested_at: AwareDatetimeISO | None = Field( + default=None, + description="When the user most recently requested an offerwall.", + ) + last_profiling_submission_at: AwareDatetimeISO | None = Field( + default=None, + description="When the user most recently submitted profiling answers.", ) # --- Prefetch Fields --- -- cgit v1.2.3