Some checks failed
CI / build (push) Failing after 3s
PHASE 2.3 (AUDIT-2026-Q3.md):
Task 1 — Wire-in Wave 3 (1 router mounted, 2 deferred):
- app.routers.unified_scanner_router mounted at /api/v2/scanner/* (2 routes:
POST /api/v2/scanner/token/scan, POST /api/v2/scanner/wallet/scan).
Refactored prefix from /api/v2 -> /api/v2/scanner to avoid future conflicts
with the v1 /api/v1/scanner/ stub.
- app.routers.unified_wallet_scanner DEFERRED (no router APIRouter attribute;
library module consumed by unified_scanner_router via get_wallet_scanner()).
- app.routers.admin_extensions DEFERRED (DORMANT per audit; 25 routes at
/api/v1/admin/* would shadow /api/v1/admin/alerts_webhook).
Task 2 — Archive 136 dead-code files to app/_archive/legacy_2026_07/:
- 73 routers in app/routers/ (reach graph showed zero reach into mount.py).
- 63 flat app/*.py (domain modules never imported by live code).
- 1 file RESTORED post-archive: app/routers/x402_bridge_health.py (caught by
tests/unit/test_bridge_health.py which directly imports it; reach graph
considered tests/ only as transitive reach — to be patched in next cycle).
Forced-LIVE (NOT archived per user directive):
- app/ai_pipeline_v3.py (3 importers in audit window, importers themselves DEAD)
- app/splade_bm25.py (LIVE via app.rag_service)
- app/wallet_manager_v2.py (LIVE via x402_enforcement, x402_tools, sweep_all, sweep_now)
- app/crypto_embeddings.py (NOT in audit ARCHIVE list; heavy import graph)
Verification (forward-import closure from mount.py + main.py + factory.py + lifespan.py):
- imports = 348 app.* modules
- reached = 194 files reachable from roots
- archive set = audit_dead (186) - reached - forced_live (4) - test_live (1) = 136
- Net delta: 136 files moved, 44,932 LOC reduction, 293->295 active routes (+2 from Wave 3)
pyproject.toml updates:
- setuptools.packages.find: added exclude for app._archive*
- ruff.extend-exclude: added "app/_archive/"
- mypy.exclude: added "app/_archive/"
Smoke test: pytest tests/ — 817 passed, 3 pre-existing failures unchanged
(0 new failures; 0 routes lost; all 4 forced-LIVE files still importable).
Restoration: git mv app/_archive/legacy_2026_07/<name>.py <original-path>
and add the import to app/mount.py ROUTER_MODULES.
Refs: AUDIT-2026-Q3.md /home/dev/pry/rmi-final-deadcode-2026-07-06.md
612 lines
19 KiB
Python
612 lines
19 KiB
Python
"""
|
|
RMI Admin User Management API
|
|
=============================
|
|
Admin endpoints for managing users:
|
|
- List all users with filters (tier, role, status, date range)
|
|
- Get user details
|
|
- Warn user (add warning note)
|
|
- Flag user as suspicious
|
|
- Ban / unban user
|
|
- Delete user (soft delete)
|
|
- Update user tier/role
|
|
- Bulk actions
|
|
|
|
All endpoints require ADMIN or SUPERADMIN role.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
from enum import StrEnum
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.core.redis import get_redis
|
|
|
|
logger = logging.getLogger("rmi_admin_users")
|
|
router = APIRouter(tags=["admin-users"])
|
|
|
|
|
|
# ── Redis helper ──
|
|
async def require_admin(request: Request, min_role: str = "ADMIN"):
|
|
"""Require admin authentication."""
|
|
from app.auth import get_current_user
|
|
|
|
user = await get_current_user(request)
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
role = user.get("role", "USER")
|
|
user.get("tier", "FREE")
|
|
|
|
# Role hierarchy
|
|
role_levels = {"USER": 0, "MODERATOR": 1, "ADMIN": 2, "SUPERADMIN": 3}
|
|
required_level = role_levels.get(min_role, 2)
|
|
user_level = role_levels.get(role, 0)
|
|
|
|
if user_level < required_level:
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
return user
|
|
|
|
|
|
# ── Models ──
|
|
class UserStatus(StrEnum):
|
|
ACTIVE = "active"
|
|
WARNED = "warned"
|
|
SUSPECT = "suspect"
|
|
BANNED = "banned"
|
|
DELETED = "deleted"
|
|
|
|
|
|
class UserListFilters(BaseModel):
|
|
tier: str | None = None
|
|
role: str | None = None
|
|
status: UserStatus | None = None
|
|
search: str | None = None
|
|
date_from: str | None = None
|
|
date_to: str | None = None
|
|
limit: int = Field(50, ge=1, le=500)
|
|
offset: int = Field(0, ge=0)
|
|
|
|
|
|
class WarnUserRequest(BaseModel):
|
|
reason: str = Field(..., min_length=1, max_length=500)
|
|
severity: str = Field("medium", pattern="^(low|medium|high|critical)$")
|
|
|
|
|
|
class UpdateUserRequest(BaseModel):
|
|
tier: str | None = None
|
|
role: str | None = None
|
|
status: UserStatus | None = None
|
|
display_name: str | None = None
|
|
scans_remaining: int | None = None
|
|
notes: str | None = None
|
|
|
|
|
|
class BulkActionRequest(BaseModel):
|
|
user_ids: list[str]
|
|
action: str = Field(..., pattern="^(warn|ban|unban|flag_suspect|clear_suspect|delete|restore|update_tier)$")
|
|
params: dict[str, Any] | None = None
|
|
|
|
|
|
class UserAdminResponse(BaseModel):
|
|
id: str
|
|
email: str
|
|
display_name: str
|
|
wallet_address: str | None
|
|
wallet_chain: str | None
|
|
tier: str
|
|
role: str
|
|
status: str
|
|
created_at: str
|
|
last_login: str | None
|
|
xp: int
|
|
level: int
|
|
scans_used: int
|
|
scans_remaining: int
|
|
warnings: list[dict]
|
|
notes: str | None
|
|
login_methods: list[str]
|
|
|
|
|
|
# ── Helper: Get all users from Redis ──
|
|
def _get_all_users() -> list[dict]:
|
|
"""Fetch all users from Redis."""
|
|
r = get_redis()
|
|
users_raw = r.hgetall("rmi:users")
|
|
users = []
|
|
for user_id, data in users_raw.items():
|
|
try:
|
|
user = json.loads(data)
|
|
user["id"] = user_id
|
|
users.append(user)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
return users
|
|
|
|
|
|
def _get_user_full(user_id: str) -> dict | None:
|
|
"""Get user with enriched data."""
|
|
from app.auth import _get_user
|
|
|
|
user = _get_user(user_id)
|
|
if not user:
|
|
return None
|
|
|
|
r = get_redis()
|
|
|
|
# Get warnings
|
|
warnings_raw = r.hget("rmi:user_warnings", user_id)
|
|
user["warnings"] = json.loads(warnings_raw) if warnings_raw else []
|
|
|
|
# Get notes
|
|
user["notes"] = r.hget("rmi:user_notes", user_id) or ""
|
|
|
|
# Get status
|
|
user["status"] = r.hget("rmi:user_status", user_id) or "active"
|
|
|
|
# Get last login
|
|
user["last_login"] = r.hget("rmi:user_last_login", user_id)
|
|
|
|
# Get login methods
|
|
wallets_raw = r.hget("rmi:user_wallets", user_id)
|
|
wallets = json.loads(wallets_raw) if wallets_raw else []
|
|
login_methods = ["email"] if user.get("password_hash") else []
|
|
if user.get("wallet_address"):
|
|
login_methods.append("wallet")
|
|
if "google" in user.get("email", ""):
|
|
login_methods.append("google")
|
|
|
|
user["login_methods"] = login_methods
|
|
user["wallet_count"] = len(wallets)
|
|
|
|
return user
|
|
|
|
|
|
# ── Endpoints ──
|
|
|
|
|
|
@router.get("/admin/users")
|
|
async def list_users(
|
|
request: Request,
|
|
tier: str | None = Query(None),
|
|
role: str | None = Query(None),
|
|
status: str | None = Query(None),
|
|
search: str | None = Query(None),
|
|
date_from: str | None = Query(None),
|
|
date_to: str | None = Query(None),
|
|
limit: int = Query(50, ge=1, le=500),
|
|
offset: int = Query(0, ge=0),
|
|
sort_by: str = Query("created_at"),
|
|
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
|
|
):
|
|
"""List all users with filtering and pagination."""
|
|
await require_admin(request)
|
|
|
|
users = _get_all_users()
|
|
|
|
# Apply filters
|
|
filtered = []
|
|
for u in users:
|
|
# Skip deleted unless specifically filtering
|
|
user_status = get_redis().hget("rmi:user_status", u["id"]) or "active"
|
|
if user_status == "deleted" and status != "deleted":
|
|
continue
|
|
|
|
if tier and u.get("tier", "FREE") != tier.upper():
|
|
continue
|
|
if role and u.get("role", "USER") != role.upper():
|
|
continue
|
|
if status and user_status != status:
|
|
continue
|
|
if search:
|
|
search_lower = search.lower()
|
|
match = (
|
|
search_lower in u.get("email", "").lower()
|
|
or search_lower in u.get("display_name", "").lower()
|
|
or search_lower in u.get("wallet_address", "").lower()
|
|
or search_lower in u.get("id", "").lower()
|
|
)
|
|
if not match:
|
|
continue
|
|
if date_from and u.get("created_at", "") < date_from:
|
|
continue
|
|
if date_to and u.get("created_at", "") > date_to:
|
|
continue
|
|
|
|
# Enrich
|
|
u["status"] = user_status
|
|
u["warnings_count"] = len(json.loads(get_redis().hget("rmi:user_warnings", u["id"]) or "[]"))
|
|
filtered.append(u)
|
|
|
|
# Sort
|
|
reverse = sort_order == "desc"
|
|
filtered.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse)
|
|
|
|
total = len(filtered)
|
|
paginated = filtered[offset : offset + limit]
|
|
|
|
# Clean response
|
|
results = []
|
|
for u in paginated:
|
|
results.append(
|
|
{
|
|
"id": u["id"],
|
|
"email": u.get("email", ""),
|
|
"display_name": u.get("display_name", u.get("email", "")),
|
|
"wallet_address": u.get("wallet_address"),
|
|
"wallet_chain": u.get("wallet_chain"),
|
|
"tier": u.get("tier", "FREE"),
|
|
"role": u.get("role", "USER"),
|
|
"status": u.get("status", "active"),
|
|
"created_at": u.get("created_at"),
|
|
"last_login": u.get("last_login"),
|
|
"xp": u.get("xp", 0),
|
|
"level": u.get("level", 1),
|
|
"scans_used": u.get("scans_used", 0),
|
|
"scans_remaining": u.get("scans_remaining", 5),
|
|
"warnings_count": u.get("warnings_count", 0),
|
|
"login_methods": ["email"] if u.get("password_hash") else ["wallet"] if u.get("wallet_address") else [],
|
|
}
|
|
)
|
|
|
|
return {
|
|
"users": results,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"filters_applied": {
|
|
"tier": tier,
|
|
"role": role,
|
|
"status": status,
|
|
"search": search,
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/admin/users/{user_id}")
|
|
async def get_user_detail(user_id: str, request: Request):
|
|
"""Get detailed info about a specific user."""
|
|
await require_admin(request)
|
|
|
|
user = _get_user_full(user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
return user
|
|
|
|
|
|
@router.post("/admin/users/{user_id}/warn")
|
|
async def warn_user(user_id: str, req: WarnUserRequest, request: Request):
|
|
"""Add a warning to a user."""
|
|
admin = await require_admin(request, min_role="MODERATOR")
|
|
|
|
from app.auth import _get_user
|
|
|
|
user = _get_user(user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
r = get_redis()
|
|
warnings_raw = r.hget("rmi:user_warnings", user_id)
|
|
warnings = json.loads(warnings_raw) if warnings_raw else []
|
|
|
|
warning = {
|
|
"id": secrets.token_hex(8),
|
|
"reason": req.reason,
|
|
"severity": req.severity,
|
|
"issued_by": admin["id"],
|
|
"issued_at": datetime.utcnow().isoformat(),
|
|
"acknowledged": False,
|
|
}
|
|
warnings.append(warning)
|
|
r.hset("rmi:user_warnings", user_id, json.dumps(warnings))
|
|
|
|
# Update status to warned
|
|
if req.severity in ("high", "critical"):
|
|
r.hset("rmi:user_status", user_id, "warned")
|
|
|
|
logger.info(f"[ADMIN] User {user_id} warned by {admin['id']}: {req.reason}")
|
|
|
|
return {"status": "ok", "warning": warning}
|
|
|
|
|
|
@router.post("/admin/users/{user_id}/flag-suspect")
|
|
async def flag_suspect(user_id: str, request: Request, reason: str | None = Query(None)):
|
|
"""Flag a user as suspicious."""
|
|
admin = await require_admin(request, min_role="MODERATOR")
|
|
|
|
from app.auth import _get_user
|
|
|
|
user = _get_user(user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
r = get_redis()
|
|
r.hset("rmi:user_status", user_id, "suspect")
|
|
|
|
# Add note
|
|
if reason:
|
|
existing = r.hget("rmi:user_notes", user_id) or ""
|
|
note = f"[{datetime.utcnow().isoformat()}] FLAGGED SUSPECT by {admin['id']}: {reason}\n"
|
|
r.hset("rmi:user_notes", user_id, existing + note)
|
|
|
|
logger.info(f"[ADMIN] User {user_id} flagged as suspect by {admin['id']}")
|
|
|
|
return {"status": "ok", "message": "User flagged as suspect"}
|
|
|
|
|
|
@router.post("/admin/users/{user_id}/clear-suspect")
|
|
async def clear_suspect(user_id: str, request: Request):
|
|
"""Clear suspect flag from a user."""
|
|
admin = await require_admin(request, min_role="MODERATOR")
|
|
|
|
r = get_redis()
|
|
current = r.hget("rmi:user_status", user_id)
|
|
if current == "suspect":
|
|
r.hset("rmi:user_status", user_id, "active")
|
|
|
|
logger.info(f"[ADMIN] User {user_id} suspect flag cleared by {admin['id']}")
|
|
|
|
return {"status": "ok", "message": "Suspect flag cleared"}
|
|
|
|
|
|
@router.post("/admin/users/{user_id}/ban")
|
|
async def ban_user(user_id: str, request: Request, reason: str | None = Query(None)):
|
|
"""Ban a user."""
|
|
admin = await require_admin(request, min_role="ADMIN")
|
|
|
|
from app.auth import _get_user
|
|
|
|
user = _get_user(user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
# Prevent self-ban
|
|
if user_id == admin["id"]:
|
|
raise HTTPException(status_code=400, detail="Cannot ban yourself")
|
|
|
|
r = get_redis()
|
|
r.hset("rmi:user_status", user_id, "banned")
|
|
|
|
if reason:
|
|
existing = r.hget("rmi:user_notes", user_id) or ""
|
|
note = f"[{datetime.utcnow().isoformat()}] BANNED by {admin['id']}: {reason}\n"
|
|
r.hset("rmi:user_notes", user_id, existing + note)
|
|
|
|
# Invalidate sessions (optional: delete JWTs)
|
|
logger.info(f"[ADMIN] User {user_id} banned by {admin['id']}: {reason}")
|
|
|
|
return {"status": "ok", "message": "User banned"}
|
|
|
|
|
|
@router.post("/admin/users/{user_id}/unban")
|
|
async def unban_user(user_id: str, request: Request):
|
|
"""Unban a user."""
|
|
admin = await require_admin(request, min_role="ADMIN")
|
|
|
|
r = get_redis()
|
|
r.hset("rmi:user_status", user_id, "active")
|
|
|
|
logger.info(f"[ADMIN] User {user_id} unbanned by {admin['id']}")
|
|
|
|
return {"status": "ok", "message": "User unbanned"}
|
|
|
|
|
|
@router.post("/admin/users/{user_id}/delete")
|
|
async def delete_user(user_id: str, request: Request, reason: str | None = Query(None)):
|
|
"""Soft delete a user."""
|
|
admin = await require_admin(request, min_role="ADMIN")
|
|
|
|
from app.auth import _get_user, _save_user
|
|
|
|
user = _get_user(user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
if user_id == admin["id"]:
|
|
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
|
|
|
# Soft delete: mark as deleted but keep data
|
|
user["deleted_at"] = datetime.utcnow().isoformat()
|
|
user["deleted_by"] = admin["id"]
|
|
user["delete_reason"] = reason or ""
|
|
_save_user(user)
|
|
|
|
r = get_redis()
|
|
r.hset("rmi:user_status", user_id, "deleted")
|
|
|
|
logger.info(f"[ADMIN] User {user_id} soft-deleted by {admin['id']}: {reason}")
|
|
|
|
return {"status": "ok", "message": "User deleted"}
|
|
|
|
|
|
@router.post("/admin/users/{user_id}/restore")
|
|
async def restore_user(user_id: str, request: Request):
|
|
"""Restore a soft-deleted user."""
|
|
admin = await require_admin(request, min_role="ADMIN")
|
|
|
|
from app.auth import _get_user, _save_user
|
|
|
|
user = _get_user(user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
user.pop("deleted_at", None)
|
|
user.pop("deleted_by", None)
|
|
user.pop("delete_reason", None)
|
|
_save_user(user)
|
|
|
|
r = get_redis()
|
|
r.hset("rmi:user_status", user_id, "active")
|
|
|
|
logger.info(f"[ADMIN] User {user_id} restored by {admin['id']}")
|
|
|
|
return {"status": "ok", "message": "User restored"}
|
|
|
|
|
|
@router.patch("/admin/users/{user_id}")
|
|
async def update_user(user_id: str, req: UpdateUserRequest, request: Request):
|
|
"""Update user properties (tier, role, status, etc.)."""
|
|
admin = await require_admin(request, min_role="ADMIN")
|
|
|
|
from app.auth import _get_user, _save_user
|
|
|
|
user = _get_user(user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
changes = []
|
|
|
|
if req.tier is not None:
|
|
old = user.get("tier", "FREE")
|
|
user["tier"] = req.tier.upper()
|
|
changes.append(f"tier: {old} -> {req.tier.upper()}")
|
|
|
|
if req.role is not None:
|
|
old = user.get("role", "USER")
|
|
# Prevent promoting to superadmin unless you're superadmin
|
|
if req.role.upper() == "SUPERADMIN" and admin.get("role") != "SUPERADMIN":
|
|
raise HTTPException(status_code=403, detail="Only superadmin can assign superadmin role")
|
|
user["role"] = req.role.upper()
|
|
changes.append(f"role: {old} -> {req.role.upper()}")
|
|
|
|
if req.status is not None:
|
|
r = get_redis()
|
|
r.hset("rmi:user_status", user_id, req.status.value)
|
|
changes.append(f"status: -> {req.status.value}")
|
|
|
|
if req.display_name is not None:
|
|
user["display_name"] = req.display_name[:50]
|
|
changes.append("display_name updated")
|
|
|
|
if req.scans_remaining is not None:
|
|
user["scans_remaining"] = req.scans_remaining
|
|
changes.append(f"scans_remaining: -> {req.scans_remaining}")
|
|
|
|
if req.notes is not None:
|
|
r = get_redis()
|
|
existing = r.hget("rmi:user_notes", user_id) or ""
|
|
note = f"[{datetime.utcnow().isoformat()}] {admin['id']}: {req.notes}\n"
|
|
r.hset("rmi:user_notes", user_id, existing + note)
|
|
changes.append("notes added")
|
|
|
|
user["updated_at"] = datetime.utcnow().isoformat()
|
|
user["updated_by"] = admin["id"]
|
|
_save_user(user)
|
|
|
|
logger.info(f"[ADMIN] User {user_id} updated by {admin['id']}: {', '.join(changes)}")
|
|
|
|
return {"status": "ok", "changes": changes}
|
|
|
|
|
|
@router.post("/admin/users/bulk")
|
|
async def bulk_action(req: BulkActionRequest, request: Request):
|
|
"""Perform bulk actions on multiple users."""
|
|
admin = await require_admin(request, min_role="ADMIN")
|
|
|
|
results = {"success": [], "failed": []}
|
|
|
|
for user_id in req.user_ids:
|
|
try:
|
|
if req.action == "ban":
|
|
r = get_redis()
|
|
r.hset("rmi:user_status", user_id, "banned")
|
|
results["success"].append({"id": user_id, "action": "banned"})
|
|
elif req.action == "unban":
|
|
r = get_redis()
|
|
r.hset("rmi:user_status", user_id, "active")
|
|
results["success"].append({"id": user_id, "action": "unbanned"})
|
|
elif req.action == "flag_suspect":
|
|
r = get_redis()
|
|
r.hset("rmi:user_status", user_id, "suspect")
|
|
results["success"].append({"id": user_id, "action": "flagged_suspect"})
|
|
elif req.action == "clear_suspect":
|
|
r = get_redis()
|
|
r.hset("rmi:user_status", user_id, "active")
|
|
results["success"].append({"id": user_id, "action": "cleared_suspect"})
|
|
elif req.action == "delete":
|
|
from app.auth import _get_user, _save_user
|
|
|
|
user = _get_user(user_id)
|
|
if user:
|
|
user["deleted_at"] = datetime.utcnow().isoformat()
|
|
user["deleted_by"] = admin["id"]
|
|
_save_user(user)
|
|
r = get_redis()
|
|
r.hset("rmi:user_status", user_id, "deleted")
|
|
results["success"].append({"id": user_id, "action": "deleted"})
|
|
elif req.action == "restore":
|
|
from app.auth import _get_user, _save_user
|
|
|
|
user = _get_user(user_id)
|
|
if user:
|
|
user.pop("deleted_at", None)
|
|
user.pop("deleted_by", None)
|
|
_save_user(user)
|
|
r = get_redis()
|
|
r.hset("rmi:user_status", user_id, "active")
|
|
results["success"].append({"id": user_id, "action": "restored"})
|
|
elif req.action == "update_tier":
|
|
tier = req.params.get("tier", "FREE") if req.params else "FREE"
|
|
from app.auth import _get_user, _save_user
|
|
|
|
user = _get_user(user_id)
|
|
if user:
|
|
user["tier"] = tier
|
|
_save_user(user)
|
|
results["success"].append({"id": user_id, "action": f"tier_updated_to_{tier}"})
|
|
except Exception as e:
|
|
results["failed"].append({"id": user_id, "error": str(e)})
|
|
|
|
logger.info(
|
|
f"[ADMIN] Bulk action {req.action} by {admin['id']}: {len(results['success'])} success, {len(results['failed'])} failed"
|
|
)
|
|
|
|
return results
|
|
|
|
|
|
@router.get("/admin/stats")
|
|
async def get_admin_stats(request: Request):
|
|
"""Get user statistics for admin dashboard."""
|
|
await require_admin(request, min_role="VIEWER")
|
|
|
|
users = _get_all_users()
|
|
r = get_redis()
|
|
|
|
stats = {
|
|
"total_users": len(users),
|
|
"by_tier": {},
|
|
"by_role": {},
|
|
"by_status": {"active": 0, "warned": 0, "suspect": 0, "banned": 0, "deleted": 0},
|
|
"new_today": 0,
|
|
"new_this_week": 0,
|
|
"new_this_month": 0,
|
|
}
|
|
|
|
today = datetime.utcnow().date().isoformat()
|
|
week_ago = (datetime.utcnow() - timedelta(days=7)).isoformat()
|
|
month_ago = (datetime.utcnow() - timedelta(days=30)).isoformat()
|
|
|
|
for u in users:
|
|
tier = u.get("tier", "FREE")
|
|
role = u.get("role", "USER")
|
|
stats["by_tier"][tier] = stats["by_tier"].get(tier, 0) + 1
|
|
stats["by_role"][role] = stats["by_role"].get(role, 0) + 1
|
|
|
|
status = r.hget("rmi:user_status", u["id"]) or "active"
|
|
stats["by_status"][status] = stats["by_status"].get(status, 0) + 1
|
|
|
|
created = u.get("created_at", "")
|
|
if created.startswith(today):
|
|
stats["new_today"] += 1
|
|
if created >= week_ago:
|
|
stats["new_this_week"] += 1
|
|
if created >= month_ago:
|
|
stats["new_this_month"] += 1
|
|
|
|
return stats
|