91 lines
3.4 KiB
Python
91 lines
3.4 KiB
Python
"""Public (unauthenticated) read-only user leaderboard.
|
|
|
|
Mounted under the ``/api/public/`` prefix so the auth middleware lets this route
|
|
through without a session cookie. Powers the public "publish" page for the user
|
|
leaderboard — a view-only snapshot with no admin controls and no mutations.
|
|
|
|
The snapshot honors the same system-wide leaderboard settings (excluded users,
|
|
scheduled/admin/failed inclusion) as the authenticated admin endpoint, so any
|
|
accounts an admin chose to hide stay hidden on the public page as well.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
|
|
from app.gateway.routers.admin_users import (
|
|
AdminLeaderboardResponse,
|
|
_load_or_schedule_daily_leaderboard,
|
|
_resolve_range,
|
|
)
|
|
from deerflow.config.system_settings import load_system_settings
|
|
from deerflow.persistence.engine import get_session_factory
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Whitelisted under ``auth_middleware._PUBLIC_PATH_PREFIXES`` (``/api/public/``).
|
|
router = APIRouter(prefix="/api/public/leaderboard", tags=["public-leaderboard"])
|
|
|
|
|
|
def _mask_email(email: str | None) -> str:
|
|
"""Reduce an email to its local part so the public page can show a
|
|
recognizable handle without leaking the full address (domain).
|
|
|
|
The leaderboard only makes sense if it names its top users, so the local
|
|
part is kept intentionally; the privacy gain is dropping the domain and the
|
|
raw account id from the unauthenticated payload.
|
|
"""
|
|
if not email:
|
|
return ""
|
|
return email.split("@", 1)[0].strip() or ""
|
|
|
|
|
|
def _anonymize(resp: AdminLeaderboardResponse) -> AdminLeaderboardResponse:
|
|
"""Strip PII from a leaderboard snapshot before it leaves over the public,
|
|
unauthenticated endpoint: email → local part, account/thread ids → opaque
|
|
short hashes (kept stable so they still work as React keys)."""
|
|
|
|
def hashed(value: str | None) -> str:
|
|
if not value:
|
|
return ""
|
|
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:12]
|
|
|
|
seen_users: set[int] = set()
|
|
for bucket in (resp.users_by_activity, resp.users_by_questions, resp.users_by_tokens):
|
|
for user in bucket:
|
|
if id(user) in seen_users:
|
|
continue
|
|
seen_users.add(id(user))
|
|
user.email = _mask_email(user.email)
|
|
user.user_id = hashed(user.user_id)
|
|
|
|
return resp
|
|
|
|
|
|
@router.get("", response_model=AdminLeaderboardResponse)
|
|
@router.get("/", response_model=AdminLeaderboardResponse)
|
|
async def get_public_leaderboard(
|
|
days: int = Query(default=30, ge=1, le=365),
|
|
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
|
|
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
|
|
limit: int = Query(default=20, ge=5, le=100),
|
|
) -> AdminLeaderboardResponse:
|
|
"""Return a read-only leaderboard snapshot for the public publish page."""
|
|
session_factory = get_session_factory()
|
|
if session_factory is None:
|
|
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
|
|
|
|
settings = load_system_settings().leaderboard
|
|
since_dt, until_dt, _range_days = _resolve_range(days, since, until)
|
|
resp = await _load_or_schedule_daily_leaderboard(
|
|
session_factory,
|
|
settings,
|
|
since_dt,
|
|
until_dt,
|
|
limit=limit,
|
|
)
|
|
return _anonymize(resp)
|