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

865 lines
30 KiB
Python
Raw Normal View History

2026-04-26 05:46:52 +07:00
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
2026-04-26 05:46:52 +07:00
from app.models.usage_event import UsageEvent
from app.models.user import User
from app.schemas.admin import (
AdminProjectResponse,
AdminSetupRequest,
AdminUserResponse,
AdminUserUpdateRequest,
2026-04-26 05:46:52 +07:00
BoardBreakdown,
BoardDiversityBucket,
BoardDiversityResponse,
BoardsResponse,
CountriesResponse,
CountryEntry,
DailyProjectActivity,
DailyTotals,
2026-04-26 05:46:52 +07:00
OverviewResponse,
ProjectMetricsResponse,
TimeseriesPoint,
TimeseriesResponse,
TopProjectEntry,
TopUserEntry,
UserDailyActivityResponse,
2026-04-26 05:46:52 +07:00
UserMetricsResponse,
)
from app.utils.slug import is_valid_username
router = APIRouter()
2026-04-26 05:46:52 +07:00
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)
2026-04-26 05:46:52 +07:00
return await _user_with_metrics(db, user)
# ── Users ─────────────────────────────────────────────────────────────────────
2026-04-26 05:46:52 +07:00
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
2026-04-26 05:46:52 +07:00
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,
2026-04-26 05:46:52 +07:00
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()
2026-04-26 05:46:52 +07:00
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.")
2026-04-26 05:46:52 +07:00
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)
2026-04-26 05:46:52 +07:00
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 ──────────────────────────────────────────────────────────────────
2026-04-26 05:46:52 +07:00
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()
2026-04-26 05:46:52 +07:00
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()
2026-04-26 05:46:52 +07:00
# ── 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)
2026-04-26 05:46:52 +07:00
).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(
2026-04-26 05:46:52 +07:00
(UsageEvent.event_type.in_(("compile", "compile_error")), 1),
else_=0,
)
).label("compiles"),
func.sum(
case((UsageEvent.event_type == "run", 1), else_=0)
2026-04-26 05:46:52 +07:00
).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,
)
2026-04-26 05:46:52 +07:00
@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],
)