-
-
+
+
+
>
)}
diff --git a/offline-backend-20260512/backend/app/gateway/routers/admin_users.py b/offline-backend-20260512/backend/app/gateway/routers/admin_users.py
index b60f1618..723fe09d 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/admin_users.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/admin_users.py
@@ -146,8 +146,27 @@ def _iso(value) -> str:
return value.isoformat() if isinstance(value, datetime) else str(value or "")
-def _range_start(days: int) -> datetime:
- return _now() - timedelta(days=max(1, min(days, 365)))
+def _parse_datetime(value: str | None, fallback: datetime) -> datetime:
+ if not value:
+ return fallback
+ text = value.strip()
+ if not text:
+ return fallback
+ if text.endswith("Z"):
+ text = text[:-1] + "+00:00"
+ try:
+ return datetime.fromisoformat(text)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=f"Invalid datetime: {value}") from exc
+
+
+def _resolve_range(days: int, since: str | None, until: str | None) -> tuple[datetime, datetime, int]:
+ end = _parse_datetime(until, _now())
+ start = _parse_datetime(since, end - timedelta(days=max(1, min(days, 365))))
+ if start > end:
+ raise HTTPException(status_code=400, detail="since must be before until")
+ span_days = max(1, min(365, (end - start).days + 1))
+ return start, end, span_days
def _question_count_expr():
@@ -403,6 +422,8 @@ async def _query_overview(session, since: datetime, until: datetime, *, user_id:
async def get_user_leaderboard(
request: Request,
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 admin analytics for user activity, Q&A, skills, questions, and trends."""
@@ -411,21 +432,20 @@ async def get_user_leaderboard(
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
- until = _now()
- since = _range_start(days)
+ since_dt, until_dt, range_days = _resolve_range(days, since, until)
async with session_factory() as session:
- overview = await _query_overview(session, since, until)
- users = await _query_user_leaderboard(session, since, until)
- skills = await _query_call_rankings(session, since, until, user_id=None, field="skill", limit=limit)
- tools = await _query_call_rankings(session, since, until, user_id=None, field="tool", limit=limit)
- recent_questions = await _query_recent_questions(session, since, until, user_id=None, limit=limit)
- frequent_questions = await _query_frequent_questions(session, since, until, limit=limit)
- trends = await _query_trends(session, since, until)
+ overview = await _query_overview(session, since_dt, until_dt)
+ users = await _query_user_leaderboard(session, since_dt, until_dt)
+ skills = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="skill", limit=limit)
+ tools = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="tool", limit=limit)
+ recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit)
+ frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit)
+ trends = await _query_trends(session, since_dt, until_dt)
return AdminLeaderboardResponse(
- range_days=days,
- since=_iso(since),
- until=_iso(until),
+ range_days=range_days,
+ since=_iso(since_dt),
+ until=_iso(until_dt),
overview=overview,
users_by_activity=sorted(users, key=lambda u: (u.run_count + u.tool_call_count, u.total_tokens), reverse=True)[:limit],
users_by_questions=sorted(users, key=lambda u: (u.qa_count, u.run_count), reverse=True)[:limit],
@@ -443,6 +463,8 @@ async def get_user_analytics(
user_id: str,
request: Request,
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),
) -> AdminUserAnalyticsResponse:
"""Return one user's admin analytics profile."""
@@ -451,22 +473,21 @@ async def get_user_analytics(
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
- until = _now()
- since = _range_start(days)
+ since_dt, until_dt, range_days = _resolve_range(days, since, until)
async with session_factory() as session:
- users = await _query_user_leaderboard(session, since, until, user_id=user_id)
+ users = await _query_user_leaderboard(session, since_dt, until_dt, user_id=user_id)
if not users:
raise HTTPException(status_code=404, detail="User not found")
- overview = await _query_overview(session, since, until, user_id=user_id)
- skills = await _query_call_rankings(session, since, until, user_id=user_id, field="skill", limit=limit)
- tools = await _query_call_rankings(session, since, until, user_id=user_id, field="tool", limit=limit)
- recent_questions = await _query_recent_questions(session, since, until, user_id=user_id, limit=limit)
- trends = await _query_trends(session, since, until, user_id=user_id)
+ overview = await _query_overview(session, since_dt, until_dt, user_id=user_id)
+ skills = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="skill", limit=limit)
+ tools = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="tool", limit=limit)
+ recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=user_id, limit=limit)
+ trends = await _query_trends(session, since_dt, until_dt, user_id=user_id)
return AdminUserAnalyticsResponse(
- range_days=days,
- since=_iso(since),
- until=_iso(until),
+ range_days=range_days,
+ since=_iso(since_dt),
+ until=_iso(until_dt),
user=users[0],
overview=overview,
skills=skills,