feat(server): admin users list surfaces is_test_account

UserDetails carries the new column, search_users selects and
optionally filters on it, and the /api/admin/users route accepts
?include_test=false to hide soak-harness accounts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-11 01:09:37 -04:00
parent b5a25b4ae5
commit 917ef2a239
2 changed files with 24 additions and 0 deletions

View File

@@ -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]}

View File

@@ -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(