diff --git a/server/routers/admin.py b/server/routers/admin.py index f0c8a17..0600feb 100644 --- a/server/routers/admin.py +++ b/server/routers/admin.py @@ -84,6 +84,7 @@ async def list_users( offset: int = 0, include_banned: bool = True, include_deleted: bool = False, + include_test: bool = True, admin: User = Depends(require_admin_v2), service: AdminService = Depends(get_admin_service_dep), ): @@ -96,6 +97,10 @@ async def list_users( offset: Results to skip. include_banned: Include banned users. include_deleted: Include soft-deleted users. + include_test: Include soak-harness test accounts (default true). + Admins see all accounts by default; pass ?include_test=false + to hide test accounts. Public stats endpoints default to + hiding them. """ users = await service.search_users( query=query, @@ -103,6 +108,7 @@ async def list_users( offset=offset, include_banned=include_banned, include_deleted=include_deleted, + include_test=include_test, ) return {"users": [u.to_dict() for u in users]} diff --git a/server/services/admin_service.py b/server/services/admin_service.py index c8af7ff..1bea94e 100644 --- a/server/services/admin_service.py +++ b/server/services/admin_service.py @@ -38,6 +38,7 @@ class UserDetails: is_active: bool games_played: int games_won: int + is_test_account: bool = False def to_dict(self) -> dict: return { @@ -55,6 +56,7 @@ class UserDetails: "is_active": self.is_active, "games_played": self.games_played, "games_won": self.games_won, + "is_test_account": self.is_test_account, } @@ -318,6 +320,7 @@ class AdminService: offset: int = 0, include_banned: bool = True, include_deleted: bool = False, + include_test: bool = True, ) -> List[UserDetails]: """ Search users by username or email. @@ -328,6 +331,10 @@ class AdminService: offset: Number of results to skip. include_banned: Include banned users. include_deleted: Include soft-deleted users. + include_test: Include soak-harness test accounts (default True). + Admins see all accounts by default; the admin UI provides a + toggle to hide test accounts. Public stats endpoints use the + opposite default (False) so real users never see soak traffic. Returns: List of user details. @@ -338,6 +345,7 @@ class AdminService: u.email_verified, u.is_banned, u.ban_reason, u.force_password_reset, u.created_at, u.last_login, u.last_seen_at, u.is_active, + COALESCE(u.is_test_account, FALSE) as is_test_account, COALESCE(s.games_played, 0) as games_played, COALESCE(s.games_won, 0) as games_won FROM users_v2 u @@ -358,6 +366,9 @@ class AdminService: if not include_deleted: sql += " AND u.deleted_at IS NULL" + if not include_test: + sql += " AND (u.is_test_account = FALSE OR u.is_test_account IS NULL)" + sql += f" ORDER BY u.created_at DESC LIMIT ${param_num} OFFSET ${param_num + 1}" params.extend([limit, offset]) @@ -379,6 +390,7 @@ class AdminService: is_active=row["is_active"], games_played=row["games_played"] or 0, games_won=row["games_won"] or 0, + is_test_account=row["is_test_account"], ) for row in rows ] @@ -387,6 +399,10 @@ class AdminService: """ Get detailed user info by ID. + Note: Returns the user regardless of is_test_account status. + Filtering by test-account only applies to list views + (search_users). If you know the ID, you get the row. + Args: user_id: User UUID. @@ -400,6 +416,7 @@ class AdminService: u.email_verified, u.is_banned, u.ban_reason, u.force_password_reset, u.created_at, u.last_login, u.last_seen_at, u.is_active, + COALESCE(u.is_test_account, FALSE) as is_test_account, COALESCE(s.games_played, 0) as games_played, COALESCE(s.games_won, 0) as games_won FROM users_v2 u @@ -427,6 +444,7 @@ class AdminService: is_active=row["is_active"], games_played=row["games_played"] or 0, games_won=row["games_won"] or 0, + is_test_account=row["is_test_account"], ) async def ban_user(