diff --git a/server/app.py b/server/app.py index ae50578..60b1353 100644 --- a/server/app.py +++ b/server/app.py @@ -13,6 +13,7 @@ import logging import os import re import uuid +from datetime import datetime, timedelta, timezone from typing import Any, Optional from urllib.parse import urlparse @@ -22,7 +23,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import select, delete, func +from sqlalchemy import select, delete, func, or_ from sqlalchemy.orm import Session from starlette.middleware.sessions import SessionMiddleware @@ -3413,6 +3414,101 @@ def usage_ping(body: UsageIn, user: models.User = Depends(auth.get_current_user) return {"ok": True} +def _parse_date_q(v: str, end: bool = False) -> Optional[datetime]: + """Accepts a plain YYYY-MM-DD (what a sends) or a full + ISO datetime. A date-only `to` means "through the end of that day", not + midnight at its start - otherwise a range of "today" would match nothing + from today at all.""" + if not v: + return None + try: + d = datetime.fromisoformat(v) + except ValueError: + return None + if d.tzinfo is None: + d = d.replace(tzinfo=timezone.utc) + if end and len(v) <= 10: # date-only + d = d + timedelta(days=1) - timedelta(microseconds=1) + return d + + +def _usage_query(db: Session, caller: "models.User", date_from, date_to, project_id, username, tool): + """Shared by the summary and export endpoints so the two can never + disagree about which rows a filter set matches - the export is a raw + dump of exactly what the summary counted, not a separately-derived view.""" + stmt = select(models.UsageEvent) + managed = managed_project_ids(db, caller) + if managed is not None: + # A project_super_user (never an app admin, who gets managed=None) is + # scoped to events tied to a project they administer, plus their OWN + # suite-wide activity (admin console opens etc. carry no project_id) - + # never another user's activity outside what they manage. + if managed: + stmt = stmt.where(or_( + models.UsageEvent.project_id.in_(managed), + models.UsageEvent.username == caller.username, + )) + else: + stmt = stmt.where(models.UsageEvent.username == caller.username) + df = _parse_date_q(date_from) + dt = _parse_date_q(date_to, end=True) + if df: + stmt = stmt.where(models.UsageEvent.at >= df) + if dt: + stmt = stmt.where(models.UsageEvent.at <= dt) + if project_id: + stmt = stmt.where(models.UsageEvent.project_id == project_id) + if username: + stmt = stmt.where(models.UsageEvent.username == username) + if tool: + stmt = stmt.where(models.UsageEvent.tool == tool) + return stmt.order_by(models.UsageEvent.at) + + +@app.get("/api/usage/summary") +def usage_summary( + date_from: Optional[str] = Query(None, alias="from"), + date_to: Optional[str] = Query(None, alias="to"), + project_id: Optional[str] = Query(None), + username: Optional[str] = Query(None), + tool: Optional[str] = Query(None), + caller: models.User = Depends(require_user_manager), + db: Session = Depends(get_db), +): + """CR-019. Same gate as the User Directory (require_user_manager): an app + admin or a project_super_user with at least one managed project. Filters + combine. Aggregated in Python over the filtered row set rather than a SQL + GROUP BY - correct and simple at today's scale; if usage_events grows + into the millions (plausible, given retention is indefinite by decision), + the day/week/month bucketing here is the first thing to move server-side + into SQL. Not done now because nothing currently requires it.""" + rows = db.scalars(_usage_query(db, caller, date_from, date_to, project_id, username, tool)).all() + + by_day: dict[str, set] = {} + by_week: dict[str, set] = {} + by_month: dict[str, set] = {} + per_user_last: dict[str, datetime] = {} + per_tool: dict[str, int] = {} + for e in rows: + by_day.setdefault(e.at.date().isoformat(), set()).add(e.username) + by_week.setdefault(e.at.strftime("%G-W%V"), set()).add(e.username) + by_month.setdefault(e.at.strftime("%Y-%m"), set()).add(e.username) + if e.username not in per_user_last or e.at > per_user_last[e.username]: + per_user_last[e.username] = e.at + per_tool[e.tool] = per_tool.get(e.tool, 0) + 1 + + return { + "active_users": { + "by_day": {k: len(v) for k, v in sorted(by_day.items())}, + "by_week": {k: len(v) for k, v in sorted(by_week.items())}, + "by_month": {k: len(v) for k, v in sorted(by_month.items())}, + }, + "per_user_last_active": {u: models._iso(t) for u, t in sorted(per_user_last.items())}, + "by_tool": dict(sorted(per_tool.items(), key=lambda kv: -kv[1])), + "event_count": len(rows), + } + + @app.get("/api/comments") def list_comments( source: Optional[str] = Query(None),