feat(dashboard): add Eastern-time activity overview and submission calendar

This commit is contained in:
yuxuanhui
2026-09-12 00:04:57 +08:00
parent 5d7c31810e
commit 9372c47580
9 changed files with 1203 additions and 449 deletions
+74 -2
View File
@@ -1,8 +1,9 @@
"""Read-only Pyramid dashboard with an allowlisted, stable browser contract."""
"""Read-only platform dashboard with an allowlisted, stable browser contract."""
import math
from datetime import date, datetime, timezone
from datetime import date, datetime, timedelta, timezone
from typing import Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
@@ -12,6 +13,77 @@ from .security import require_auth
from .worldquant import WqError
router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"], dependencies=[Depends(require_auth)])
EASTERN = ZoneInfo("America/New_York")
class ActivityDay(BaseModel):
date: date
count: int | None
class ActivitySnapshot(BaseModel):
items: list[ActivityDay]
today: date
timezone: str = "America/New_York"
fetched_at: datetime
def activity_snapshot(data, days: int, at: datetime) -> ActivitySnapshot:
"""Return platform calendar counts, keeping absent dates unknown.
BRAIN activity dates already denote Eastern calendar days. Never interpret
them as UTC instants or replace missing records with zero. Malformed or
duplicate rows raise WqError rather than displaying misleading totals.
"""
today = at.astimezone(EASTERN).date()
start = today - timedelta(days=days - 1)
recordset = data.get("records") if isinstance(data, dict) else None
schema = recordset.get("schema") if isinstance(recordset, dict) else None
properties = schema.get("properties") if isinstance(schema, dict) else None
records = recordset.get("records") if isinstance(recordset, dict) else None
if not isinstance(properties, list) or not isinstance(records, list):
raise WqError("平台活动数据格式无法识别", "invalid_response")
names = [prop.get("name") if isinstance(prop, dict) else prop for prop in properties]
if names.count("date") != 1 or names.count("value") != 1:
raise WqError("平台活动数据格式无法识别", "invalid_response")
date_index, value_index = names.index("date"), names.index("value")
counts = {}
for row in records:
if not isinstance(row, list) or len(row) <= max(date_index, value_index):
raise WqError("平台活动数据格式无法识别", "invalid_response")
raw_date, value = row[date_index], row[value_index]
try:
day = date.fromisoformat(raw_date)
if day.isoformat() != raw_date:
raise ValueError
except (TypeError, ValueError):
raise WqError("平台活动日期格式无法识别", "invalid_response") from None
if day in counts or (value is not None and (type(value) is not int or value < 0)):
raise WqError("平台活动次数格式无法识别", "invalid_response")
counts[day] = value
return ActivitySnapshot(
items=[ActivityDay(date=day, count=counts.get(day))
for offset in range(days) for day in [start + timedelta(days=offset)]],
today=today, fetched_at=at,
)
@router.get("/activity/{metric}", response_model=ActivitySnapshot)
async def activity(request: Request, metric: Literal["submissions", "simulations"]):
"""Read each activity independently using the bound platform session."""
runner = request.app.state.runner
async with request.app.state.sessions() as db:
account = await db.get(Account, 1)
if runner.disconnecting or account.connection_status != "connected" or not account.wq_user_id:
raise HTTPException(409, "请先在个人信息页连接并确认 WorldQuant 账户")
try:
data = await runner.client.get(f"/users/self/activities/{metric}")
return activity_snapshot(data, 182 if metric == "submissions" else 14, datetime.now(timezone.utc))
except WqError as exc:
raise HTTPException(
409 if exc.code in ("disconnected", "verification_required", "authentication_failed") else 502,
str(exc),
) from None
class PyramidRow(BaseModel):
+90
View File
@@ -124,3 +124,93 @@ async def test_dashboard_auth_connection_dates_and_independent_errors(app, clien
client.cookies.clear()
for endpoint in ("pyramid-alphas", "pyramid-multipliers"):
assert (await client.get(f"{prefix}/{endpoint}")).status_code == 401
def activity_data(rows, properties=None):
return {"records": {"schema": {"properties": properties or ["date", "value"]}, "records": rows}}
def test_activity_calendar_preserves_missing_zero_and_column_order():
from app.dashboard import activity_snapshot
at = datetime.fromisoformat("2026-09-11T02:00:00+00:00")
data = activity_data([
[3, "2026-09-10", "private"], [0, "2026-09-09", "private"],
[99, "2026-09-11", "future"], [15, "2025-01-01", "old"],
], [{"name": "value"}, {"name": "date"}, {"name": "secret"}])
result = activity_snapshot(data, 14, at)
assert result.today.isoformat() == "2026-09-10"
assert len(result.items) == 14 and result.items[0].date.isoformat() == "2026-08-28"
assert [day.count for day in result.items[-3:]] == [None, 0, 3]
assert "private" not in result.model_dump_json()
assert result.timezone == "America/New_York"
assert len(activity_snapshot(data, 182, at).items) == 182
@pytest.mark.parametrize("timestamp,today", [
("2026-03-08T04:59:59+00:00", "2026-03-07"),
("2026-03-08T05:00:00+00:00", "2026-03-08"),
("2026-03-09T04:00:00+00:00", "2026-03-09"),
("2026-11-01T04:00:00+00:00", "2026-11-01"),
("2026-11-02T04:59:59+00:00", "2026-11-01"),
("2026-11-02T05:00:00+00:00", "2026-11-02"),
("2027-01-01T04:00:00+00:00", "2026-12-31"),
])
def test_activity_uses_eastern_calendar_across_dst_and_year(timestamp, today):
from app.dashboard import activity_snapshot
result = activity_snapshot(activity_data([]), 14, datetime.fromisoformat(timestamp))
assert result.today.isoformat() == today
assert all(day.count is None for day in result.items)
assert len({day.date for day in result.items}) == 14
assert (result.items[-1].date - result.items[0].date).days == 13
@pytest.mark.parametrize("data", [
{}, {"records": None}, activity_data([None]), activity_data([["2026-09-10"]]),
activity_data([["invalid", 1]]), activity_data([["20260910", 1]]),
activity_data([["2026-09-10", True]]), activity_data([["2026-09-10", -1]]),
activity_data([["2026-09-10", 1.5]]), activity_data([["2026-09-10", "3"]]),
activity_data([["2026-09-10", 1], ["2026-09-10", 2]]),
activity_data([], ["date", "date", "value"]), activity_data([], ["date"]),
])
def test_activity_rejects_malformed_or_ambiguous_counts(data):
from app.dashboard import activity_snapshot
with pytest.raises(WqError, match="格式无法识别"):
activity_snapshot(data, 14, datetime.fromisoformat("2026-09-11T12:00:00+00:00"))
async def test_activity_endpoint_auth_connection_and_independent_failures(app, client, logged_in, monkeypatch):
calls = []
async def read(path):
calls.append(path)
if path.endswith("simulations"):
raise WqError("无权访问该平台资源", "access_denied")
return activity_data([])
monkeypatch.setattr(app.state.runner.client, "get", read)
prefix = "/api/v1/dashboard/activity"
assert (await logged_in.get(f"{prefix}/submissions")).status_code == 409
assert not calls
async with app.state.sessions.begin() as db:
account = await db.get(Account, 1)
account.connection_status, account.wq_user_id = "connected", "TEST_USER"
assert (await logged_in.get(f"{prefix}/arbitrary")).status_code == 422
assert not calls
response = await logged_in.get(f"{prefix}/submissions")
assert response.status_code == 200 and len(response.json()["items"]) == 182
assert all(day["count"] is None for day in response.json()["items"])
assert (await logged_in.get(f"{prefix}/simulations")).status_code == 502
assert calls == ["/users/self/activities/submissions", "/users/self/activities/simulations"]
assert (await logged_in.get("/api/v1/auth/me")).status_code == 200
async def waiting(path):
raise WqError("需要人工验证", "verification_required")
monkeypatch.setattr(app.state.runner.client, "get", waiting)
assert (await logged_in.get(f"{prefix}/submissions")).status_code == 409
client.cookies.clear()
assert (await client.get(f"{prefix}/submissions")).status_code == 401
assert (await client.get(f"{prefix}/simulations")).status_code == 401