velxio/backend/app/api/routes/admin.py

865 lines
30 KiB
Python

from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import case, distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import require_admin
from app.core.security import hash_password
from app.database.session import get_db
from app.models.project import Project
from app.models.usage_event import UsageEvent
from app.models.user import User
from app.schemas.admin import (
AdminProjectResponse,
AdminSetupRequest,
AdminUserResponse,
AdminUserUpdateRequest,
BoardBreakdown,
BoardDiversityBucket,
BoardDiversityResponse,
BoardsResponse,
CountriesResponse,
CountryEntry,
DailyProjectActivity,
DailyTotals,
OverviewResponse,
ProjectMetricsResponse,
TimeseriesPoint,
TimeseriesResponse,
TopProjectEntry,
TopUserEntry,
UserDailyActivityResponse,
UserMetricsResponse,
)
from app.utils.slug import is_valid_username
router = APIRouter()
def _now() -> datetime:
return datetime.now(timezone.utc)
# ── Setup ─────────────────────────────────────────────────────────────────────
@router.get("/setup/status")
async def setup_status(db: AsyncSession = Depends(get_db)):
"""Check whether any admin user exists."""
result = await db.execute(select(User).where(User.is_admin == True)) # noqa: E712
has_admin = result.scalar_one_or_none() is not None
return {"has_admin": has_admin}
@router.post("/setup", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED)
async def setup_admin(body: AdminSetupRequest, db: AsyncSession = Depends(get_db)):
"""Create the first admin user. Fails if an admin already exists."""
existing_admin = await db.execute(select(User).where(User.is_admin == True)) # noqa: E712
if existing_admin.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Admin already configured.")
username = body.username.lower().strip()
if not is_valid_username(username):
raise HTTPException(
status_code=400,
detail="Username must be 3-30 chars, only lowercase letters/numbers/underscores/hyphens.",
)
if len(body.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters.")
# Check uniqueness
conflict = await db.execute(
select(User).where((User.username == username) | (User.email == body.email))
)
if conflict.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username or email already taken.")
user = User(
username=username,
email=body.email,
hashed_password=hash_password(body.password),
is_admin=True,
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
return await _user_with_metrics(db, user)
# ── Users ─────────────────────────────────────────────────────────────────────
async def _user_with_metrics(db: AsyncSession, user: User) -> AdminUserResponse:
count_result = await db.execute(
select(func.count()).where(Project.user_id == user.id)
)
project_count = count_result.scalar() or 0
boards_result = await db.execute(
select(distinct(UsageEvent.board_family))
.where(UsageEvent.user_id == user.id, UsageEvent.board_family.is_not(None))
)
boards_used = [b for b in boards_result.scalars().all() if b]
return AdminUserResponse(
id=user.id,
username=user.username,
email=user.email,
avatar_url=user.avatar_url,
is_active=user.is_active,
is_admin=user.is_admin,
created_at=user.created_at,
project_count=project_count,
total_compiles=user.total_compiles,
total_compile_errors=user.total_compile_errors,
total_runs=user.total_runs,
last_active_at=user.last_active_at,
boards_used=boards_used,
signup_country=user.signup_country,
last_country=user.last_country,
)
@router.get("/users", response_model=list[AdminUserResponse])
async def list_users(
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
result = await db.execute(select(User).order_by(User.created_at.desc()))
users = result.scalars().all()
return [await _user_with_metrics(db, u) for u in users]
@router.get("/users/{user_id}", response_model=AdminUserResponse)
async def get_user(
user_id: str,
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found.")
return await _user_with_metrics(db, user)
@router.put("/users/{user_id}", response_model=AdminUserResponse)
async def update_user(
user_id: str,
body: AdminUserUpdateRequest,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_admin),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found.")
if body.username is not None:
new_username = body.username.lower().strip()
if not is_valid_username(new_username):
raise HTTPException(status_code=400, detail="Invalid username format.")
if new_username != user.username:
conflict = await db.execute(select(User).where(User.username == new_username))
if conflict.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username already taken.")
user.username = new_username
if body.email is not None:
if body.email != user.email:
conflict = await db.execute(select(User).where(User.email == body.email))
if conflict.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already in use.")
user.email = body.email
if body.password is not None:
if len(body.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters.")
user.hashed_password = hash_password(body.password)
if body.is_active is not None:
user.is_active = body.is_active
if body.is_admin is not None:
# Prevent removing admin from yourself
if user.id == admin.id and not body.is_admin:
raise HTTPException(status_code=400, detail="Cannot remove your own admin privileges.")
user.is_admin = body.is_admin
await db.commit()
await db.refresh(user)
return await _user_with_metrics(db, user)
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_admin),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
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 your own account.")
# Delete all user's projects first
projects_result = await db.execute(select(Project).where(Project.user_id == user_id))
for project in projects_result.scalars().all():
await db.delete(project)
await db.delete(user)
await db.commit()
# ── Projects ──────────────────────────────────────────────────────────────────
def _project_to_response(project: Project, owner_username: str) -> AdminProjectResponse:
return AdminProjectResponse(
id=project.id,
name=project.name,
slug=project.slug,
description=project.description,
is_public=project.is_public,
board_type=project.board_type,
owner_username=owner_username,
owner_id=project.user_id,
created_at=project.created_at,
updated_at=project.updated_at,
compile_count=project.compile_count,
compile_error_count=project.compile_error_count,
run_count=project.run_count,
update_count=project.update_count,
last_compiled_at=project.last_compiled_at,
last_run_at=project.last_run_at,
)
@router.get("/projects", response_model=list[AdminProjectResponse])
async def list_all_projects(
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
result = await db.execute(
select(Project, User.username)
.join(User, User.id == Project.user_id)
.order_by(Project.created_at.desc())
)
rows = result.all()
return [_project_to_response(project, username) for project, username in rows]
@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(
project_id: str,
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found.")
await db.delete(project)
await db.commit()
# ── Metrics dashboard ─────────────────────────────────────────────────────────
@router.get("/metrics/overview", response_model=OverviewResponse)
async def metrics_overview(
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
now = _now()
day_ago = now - timedelta(days=1)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
total_users = (await db.execute(select(func.count(User.id)))).scalar() or 0
total_projects = (await db.execute(select(func.count(Project.id)))).scalar() or 0
public_projects = (
await db.execute(select(func.count(Project.id)).where(Project.is_public == True)) # noqa: E712
).scalar() or 0
total_compiles = (await db.execute(select(func.coalesce(func.sum(User.total_compiles), 0)))).scalar() or 0
total_compile_errors = (
await db.execute(select(func.coalesce(func.sum(User.total_compile_errors), 0)))
).scalar() or 0
total_runs = (await db.execute(select(func.coalesce(func.sum(User.total_runs), 0)))).scalar() or 0
success = max(total_compiles - total_compile_errors, 0)
success_rate = (success / total_compiles) if total_compiles else 0.0
dau = (
await db.execute(
select(func.count(distinct(User.id))).where(User.last_active_at >= day_ago)
)
).scalar() or 0
wau = (
await db.execute(
select(func.count(distinct(User.id))).where(User.last_active_at >= week_ago)
)
).scalar() or 0
mau = (
await db.execute(
select(func.count(distinct(User.id))).where(User.last_active_at >= month_ago)
)
).scalar() or 0
new_users_30d = (
await db.execute(select(func.count(User.id)).where(User.created_at >= month_ago))
).scalar() or 0
new_projects_30d = (
await db.execute(select(func.count(Project.id)).where(Project.created_at >= month_ago))
).scalar() or 0
return OverviewResponse(
total_users=total_users,
total_projects=total_projects,
public_projects=public_projects,
private_projects=total_projects - public_projects,
total_compiles=total_compiles,
total_compile_errors=total_compile_errors,
total_runs=total_runs,
compile_success_rate=round(success_rate, 4),
dau=dau,
wau=wau,
mau=mau,
new_users_30d=new_users_30d,
new_projects_30d=new_projects_30d,
)
_VALID_METRICS = {"compile", "compile_error", "run", "save", "create", "project_open"}
_VALID_BUCKETS = {"hour", "day", "week"}
@router.get("/metrics/timeseries", response_model=TimeseriesResponse)
async def metrics_timeseries(
metric: str = Query("compile"),
range_days: int = Query(30, ge=1, le=365),
bucket: str = Query("day"),
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
if metric not in _VALID_METRICS:
raise HTTPException(status_code=400, detail=f"metric must be one of {sorted(_VALID_METRICS)}")
if bucket not in _VALID_BUCKETS:
raise HTTPException(status_code=400, detail=f"bucket must be one of {sorted(_VALID_BUCKETS)}")
since = _now() - timedelta(days=range_days)
# SQLite-compatible time bucketing via strftime
if bucket == "hour":
fmt = "%Y-%m-%d %H:00"
elif bucket == "week":
fmt = "%Y-%W"
else:
fmt = "%Y-%m-%d"
bucket_expr = func.strftime(fmt, UsageEvent.created_at).label("bucket")
result = await db.execute(
select(bucket_expr, func.count(UsageEvent.id))
.where(UsageEvent.event_type == metric, UsageEvent.created_at >= since)
.group_by(bucket_expr)
.order_by(bucket_expr)
)
points = [TimeseriesPoint(bucket=row[0], value=row[1]) for row in result.all()]
return TimeseriesResponse(metric=metric, bucket=bucket, range_days=range_days, points=points)
@router.get("/metrics/boards", response_model=BoardsResponse)
async def metrics_boards(
range_days: int = Query(90, ge=1, le=365),
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
since = _now() - timedelta(days=range_days)
async def _agg(group_col):
compile_subq = (
select(
group_col.label("g"),
func.count(UsageEvent.id).label("compile_count"),
func.sum(
case((UsageEvent.event_type == "compile_error", 1), else_=0)
).label("compile_error_count"),
func.count(distinct(UsageEvent.user_id)).label("distinct_users"),
func.count(distinct(UsageEvent.project_id)).label("distinct_projects"),
)
.where(
UsageEvent.event_type.in_(("compile", "compile_error")),
UsageEvent.created_at >= since,
group_col.is_not(None),
)
.group_by(group_col)
.subquery()
)
run_subq = (
select(
group_col.label("g"),
func.count(UsageEvent.id).label("run_count"),
)
.where(
UsageEvent.event_type == "run",
UsageEvent.created_at >= since,
group_col.is_not(None),
)
.group_by(group_col)
.subquery()
)
rows = (
await db.execute(
select(
compile_subq.c.g,
compile_subq.c.compile_count,
compile_subq.c.compile_error_count,
func.coalesce(run_subq.c.run_count, 0),
compile_subq.c.distinct_users,
compile_subq.c.distinct_projects,
).outerjoin(run_subq, compile_subq.c.g == run_subq.c.g)
)
).all()
return rows
family_rows = await _agg(UsageEvent.board_family)
fqbn_rows = await _agg(UsageEvent.board_fqbn)
families = [
BoardBreakdown(
board_family=row[0],
board_fqbn=None,
compile_count=row[1] or 0,
compile_error_count=row[2] or 0,
run_count=row[3] or 0,
distinct_users=row[4] or 0,
distinct_projects=row[5] or 0,
)
for row in family_rows
]
fqbns = [
BoardBreakdown(
board_family=None,
board_fqbn=row[0],
compile_count=row[1] or 0,
compile_error_count=row[2] or 0,
run_count=row[3] or 0,
distinct_users=row[4] or 0,
distinct_projects=row[5] or 0,
)
for row in fqbn_rows
]
families.sort(key=lambda b: b.compile_count, reverse=True)
fqbns.sort(key=lambda b: b.compile_count, reverse=True)
return BoardsResponse(families=families, fqbns=fqbns)
@router.get("/metrics/board-diversity", response_model=BoardDiversityResponse)
async def metrics_board_diversity(
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
"""How many distinct board families each user has compiled.
Critical signal for freemium tiering: users sticking to one board are
cheap to serve; multi-board users likely warrant paid tiers.
"""
per_user = (
select(
UsageEvent.user_id,
func.count(distinct(UsageEvent.board_family)).label("n"),
)
.where(
UsageEvent.event_type.in_(("compile", "compile_error")),
UsageEvent.user_id.is_not(None),
UsageEvent.board_family.is_not(None),
)
.group_by(UsageEvent.user_id)
.subquery()
)
rows = (await db.execute(select(per_user.c.n))).scalars().all()
buckets = {"1": 0, "2": 0, "3+": 0}
for n in rows:
if n <= 1:
buckets["1"] += 1
elif n == 2:
buckets["2"] += 1
else:
buckets["3+"] += 1
return BoardDiversityResponse(
buckets=[BoardDiversityBucket(bucket=k, user_count=v) for k, v in buckets.items()],
total_users_with_compiles=len(rows),
)
_VALID_TOP_METRICS = {"compiles": User.total_compiles, "runs": User.total_runs}
@router.get("/metrics/top-users", response_model=list[TopUserEntry])
async def metrics_top_users(
metric: str = Query("compiles"),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
if metric not in _VALID_TOP_METRICS:
raise HTTPException(status_code=400, detail="metric must be 'compiles' or 'runs'")
col = _VALID_TOP_METRICS[metric]
rows = (
await db.execute(
select(User.id, User.username, col)
.where(col > 0)
.order_by(col.desc())
.limit(limit)
)
).all()
return [TopUserEntry(user_id=r[0], username=r[1], value=r[2] or 0) for r in rows]
_VALID_TOP_PROJECT_METRICS = {
"compiles": Project.compile_count,
"runs": Project.run_count,
"updates": Project.update_count,
}
@router.get("/metrics/top-projects", response_model=list[TopProjectEntry])
async def metrics_top_projects(
metric: str = Query("compiles"),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
if metric not in _VALID_TOP_PROJECT_METRICS:
raise HTTPException(status_code=400, detail="metric must be 'compiles', 'runs' or 'updates'")
col = _VALID_TOP_PROJECT_METRICS[metric]
rows = (
await db.execute(
select(Project.id, Project.name, User.username, col)
.join(User, User.id == Project.user_id)
.where(col > 0)
.order_by(col.desc())
.limit(limit)
)
).all()
return [
TopProjectEntry(project_id=r[0], project_name=r[1], owner_username=r[2], value=r[3] or 0)
for r in rows
]
@router.get("/metrics/users/{user_id}", response_model=UserMetricsResponse)
async def metrics_for_user(
user_id: str,
range_days: int = Query(30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found.")
boards = [
b for b in (
await db.execute(
select(distinct(UsageEvent.board_family)).where(
UsageEvent.user_id == user_id, UsageEvent.board_family.is_not(None)
)
)
).scalars().all() if b
]
fqbns = [
f for f in (
await db.execute(
select(distinct(UsageEvent.board_fqbn)).where(
UsageEvent.user_id == user_id, UsageEvent.board_fqbn.is_not(None)
)
)
).scalars().all() if f
]
project_count = (
await db.execute(select(func.count(Project.id)).where(Project.user_id == user_id))
).scalar() or 0
since = _now() - timedelta(days=range_days)
bucket_expr = func.strftime("%Y-%m-%d", UsageEvent.created_at).label("bucket")
rows = (
await db.execute(
select(bucket_expr, func.count(UsageEvent.id))
.where(
UsageEvent.user_id == user_id,
UsageEvent.event_type == "compile",
UsageEvent.created_at >= since,
)
.group_by(bucket_expr)
.order_by(bucket_expr)
)
).all()
return UserMetricsResponse(
user_id=user.id,
username=user.username,
total_compiles=user.total_compiles,
total_compile_errors=user.total_compile_errors,
total_runs=user.total_runs,
last_active_at=user.last_active_at,
boards_used=boards,
fqbns_used=fqbns,
project_count=project_count,
timeseries=[TimeseriesPoint(bucket=r[0], value=r[1]) for r in rows],
)
@router.get("/metrics/countries", response_model=CountriesResponse)
async def metrics_countries(
range_days: int = Query(90, ge=1, le=365),
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
"""Per-country breakdown.
Combines two views:
- User-table: how many users currently set to each country (last_country)
and how many signed up from there (signup_country).
- UsageEvent: compile/run volume + distinct active users per country
within the requested range.
"""
since = _now() - timedelta(days=range_days)
# Per-country compile / run / distinct-user from events
event_rows = (
await db.execute(
select(
UsageEvent.country,
func.sum(
case(
(UsageEvent.event_type.in_(("compile", "compile_error")), 1),
else_=0,
)
).label("compiles"),
func.sum(
case((UsageEvent.event_type == "run", 1), else_=0)
).label("runs"),
func.count(distinct(UsageEvent.user_id)).label("active_users"),
)
.where(UsageEvent.created_at >= since)
.group_by(UsageEvent.country)
)
).all()
# User-table aggregates (all-time, not range-bound)
last_rows = (
await db.execute(
select(User.last_country, func.count(User.id))
.group_by(User.last_country)
)
).all()
signup_rows = (
await db.execute(
select(User.signup_country, func.count(User.id))
.group_by(User.signup_country)
)
).all()
last_map = {row[0]: row[1] for row in last_rows}
signup_map = {row[0]: row[1] for row in signup_rows}
# Union all keys we've seen
all_keys = (
{row[0] for row in event_rows}
| set(last_map.keys())
| set(signup_map.keys())
)
event_map = {row[0]: row for row in event_rows}
entries: list[CountryEntry] = []
for key in all_keys:
evt = event_map.get(key)
entries.append(
CountryEntry(
country=key,
user_count=last_map.get(key, 0),
signup_count=signup_map.get(key, 0),
compile_count=int(evt[1] or 0) if evt else 0,
run_count=int(evt[2] or 0) if evt else 0,
distinct_users_active=int(evt[3] or 0) if evt else 0,
)
)
# Sort by total signal (users + active + compiles) descending
entries.sort(
key=lambda e: (e.user_count + e.distinct_users_active, e.compile_count),
reverse=True,
)
return CountriesResponse(range_days=range_days, entries=entries)
@router.get(
"/metrics/users/{user_id}/activity",
response_model=UserDailyActivityResponse,
)
async def metrics_user_daily_activity(
user_id: str,
range_days: int = Query(30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
"""Per-day, per-project breakdown of one user's activity.
Returns one row per (day, project) tuple plus a flat per-day totals
list. Useful for answering "which projects did Alice work on
yesterday and how many times did she compile each?".
"""
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found.")
since = _now() - timedelta(days=range_days)
bucket = func.strftime("%Y-%m-%d", UsageEvent.created_at).label("day")
# Per-(day, project) counts
rows = (
await db.execute(
select(
bucket,
UsageEvent.project_id,
func.sum(
case((UsageEvent.event_type == "compile", 1), else_=0)
).label("compiles"),
func.sum(
case((UsageEvent.event_type == "compile_error", 1), else_=0)
).label("compile_errors"),
func.sum(
case((UsageEvent.event_type == "run", 1), else_=0)
).label("runs"),
func.sum(
case((UsageEvent.event_type == "save", 1), else_=0)
).label("saves"),
)
.where(UsageEvent.user_id == user_id, UsageEvent.created_at >= since)
.group_by(bucket, UsageEvent.project_id)
.order_by(bucket.desc(), func.sum(
case((UsageEvent.event_type == "compile", 1), else_=0)
).desc())
)
).all()
# Resolve project names in one query (avoid N+1)
project_ids = {r[1] for r in rows if r[1] is not None}
name_map: dict[str, str] = {}
if project_ids:
proj_rows = (
await db.execute(
select(Project.id, Project.name).where(Project.id.in_(project_ids))
)
).all()
name_map = {pid: name for pid, name in proj_rows}
entries = [
DailyProjectActivity(
date=row[0],
project_id=row[1],
project_name=name_map.get(row[1]) if row[1] else None,
compiles=int(row[2] or 0),
compile_errors=int(row[3] or 0),
runs=int(row[4] or 0),
saves=int(row[5] or 0),
)
for row in rows
]
# Roll up to per-day totals
totals_rows = (
await db.execute(
select(
bucket,
func.sum(
case((UsageEvent.event_type == "compile", 1), else_=0)
).label("compiles"),
func.sum(
case((UsageEvent.event_type == "compile_error", 1), else_=0)
).label("compile_errors"),
func.sum(
case((UsageEvent.event_type == "run", 1), else_=0)
).label("runs"),
func.sum(
case((UsageEvent.event_type == "save", 1), else_=0)
).label("saves"),
func.count(distinct(UsageEvent.project_id)).label("distinct_projects"),
)
.where(UsageEvent.user_id == user_id, UsageEvent.created_at >= since)
.group_by(bucket)
.order_by(bucket.desc())
)
).all()
daily_totals = [
DailyTotals(
date=row[0],
compiles=int(row[1] or 0),
compile_errors=int(row[2] or 0),
runs=int(row[3] or 0),
saves=int(row[4] or 0),
distinct_projects=int(row[5] or 0),
)
for row in totals_rows
]
return UserDailyActivityResponse(
user_id=user.id,
username=user.username,
range_days=range_days,
entries=entries,
daily_totals=daily_totals,
)
@router.get("/metrics/projects/{project_id}", response_model=ProjectMetricsResponse)
async def metrics_for_project(
project_id: str,
range_days: int = Query(30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
project = (await db.execute(select(Project).where(Project.id == project_id))).scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found.")
owner = (await db.execute(select(User).where(User.id == project.user_id))).scalar_one_or_none()
since = _now() - timedelta(days=range_days)
bucket_expr = func.strftime("%Y-%m-%d", UsageEvent.created_at).label("bucket")
rows = (
await db.execute(
select(bucket_expr, func.count(UsageEvent.id))
.where(
UsageEvent.project_id == project_id,
UsageEvent.event_type == "compile",
UsageEvent.created_at >= since,
)
.group_by(bucket_expr)
.order_by(bucket_expr)
)
).all()
return ProjectMetricsResponse(
project_id=project.id,
project_name=project.name,
owner_username=owner.username if owner else "",
compile_count=project.compile_count,
compile_error_count=project.compile_error_count,
run_count=project.run_count,
update_count=project.update_count,
last_compiled_at=project.last_compiled_at,
last_run_at=project.last_run_at,
timeseries=[TimeseriesPoint(bucket=r[0], value=r[1]) for r in rows],
)