feat(stats): per-user API quota attribution + admin usage page

Track who burned how much YouTube API quota. A QuotaEvent audit log (migration
0009) records every spend with the triggering user (NULL = background/system) and
an action label, set via a request/job-scoped contextvar (quota.attribute) so no
call signatures change. User-initiated work (sync subscriptions, unsubscribe,
opt-in recent backfill, manual enrich) attributes to the user; scheduler work to
System, split by action.

- backend: QuotaEvent model + migration 0009; quota.attribute() contextvar;
  record_usage logs events; entry points wrapped (routes/sync, routes/channels,
  scheduler); GET /api/quota/my-usage + GET /api/quota/admin
- frontend: admin-only Stats page (header nav, page=stats) with daily bars +
  per-user breakdown by action and range picker; 'Your API usage' in Settings ->
  Sync for every user

Verified: attribution + endpoints compute correctly; events are per-user vs System.
This commit is contained in:
npeter83 2026-06-12 02:47:55 +02:00
parent 0dad71cf6e
commit d610647d13
15 changed files with 451 additions and 26 deletions

View file

@ -0,0 +1,46 @@
"""quota_events: per-spend audit log for per-user quota attribution
Revision ID: 0009_quota_events
Revises: 0008_invites
Create Date: 2026-06-12
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0009_quota_events"
down_revision: Union[str, None] = "0008_invites"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"quota_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("action", sa.String(length=40), nullable=False),
sa.Column("units", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_quota_events_user_id", "quota_events", ["user_id"])
op.create_index("ix_quota_events_action", "quota_events", ["action"])
op.create_index("ix_quota_events_created_at", "quota_events", ["created_at"])
def downgrade() -> None:
op.drop_index("ix_quota_events_created_at", table_name="quota_events")
op.drop_index("ix_quota_events_action", table_name="quota_events")
op.drop_index("ix_quota_events_user_id", table_name="quota_events")
op.drop_table("quota_events")