admin: internal-only superadmin console (users, payments, DB stats)

New independent `admin` service (admin.main:app) on the same image, reusing
app.db/app.models read-only. Never runs migrations or the scheduler; issues
SELECTs only.

- Password-gated (ADMIN_CONSOLE_PASSWORD) with a 12h signed cookie; closed by
  default when the password is empty.
- Bound to 127.0.0.1:8091 (SSH-tunnel access); off the intranet/NPM network.
- Pages: overview stats, user list + search, per-user history/payment detail,
  DB usage (information_schema size + row estimates).
- Compose: base `admin` service (+prod DB-host override, test mount); Dockerfile
  bakes admin/ into runtime + test stages.
- Tests: tests/test_admin_console.py (auth, queries, page wiring) — 12 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-07-01 16:08:20 +02:00
parent 8946dee2e0
commit 411094d7b8
20 changed files with 1143 additions and 1 deletions

266
admin/queries.py Normal file
View file

@ -0,0 +1,266 @@
"""Read-only queries backing the console. Every function issues SELECTs
only the console never mutates the database.
Payment status is computed with the app's own ``paid_status`` so the console
never diverges from what the app considers "paid".
"""
from __future__ import annotations
from datetime import timedelta
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import utcnow
from app.models import (
EmailSend,
PortfolioSync,
Referral,
StrategicLogFeedback,
User,
UserAcknowledgement,
)
from app.services.access import paid_status
async def overview_stats(session: AsyncSession) -> dict:
"""Headline counts for the landing page: totals, tier split, growth,
paid-active, engagement."""
now = utcnow()
d7 = now - timedelta(days=7)
d30 = now - timedelta(days=30)
total = await session.scalar(select(func.count(User.id))) or 0
tier_rows = (await session.execute(
select(User.tier, func.count(User.id)).group_by(User.tier)
)).all()
tiers = {tier: n for tier, n in tier_rows}
signups_7d = await session.scalar(
select(func.count(User.id)).where(User.created_at >= d7)
) or 0
signups_30d = await session.scalar(
select(func.count(User.id)).where(User.created_at >= d30)
) or 0
active_30d = await session.scalar(
select(func.count(User.id)).where(User.last_login_at >= d30)
) or 0
# Paid-active = subscribed tier OR a live credit window. The two are
# counted disjointly (a credit user whose tier is already paid is only
# counted once, under tier) so the sum can't double-count.
paid_by_tier = await session.scalar(
select(func.count(User.id)).where(User.tier.in_(("paid", "enterprise")))
) or 0
credit_active = await session.scalar(
select(func.count(User.id)).where(
User.credit_until.is_not(None),
User.credit_until > now,
User.tier.not_in(("paid", "enterprise")),
)
) or 0
sync_enabled = await session.scalar(
select(func.count(PortfolioSync.user_id))
) or 0
referrals_total = await session.scalar(select(func.count(Referral.id))) or 0
referrals_converted = await session.scalar(
select(func.count(Referral.id)).where(Referral.converted_at.is_not(None))
) or 0
return {
"total_users": total,
"tiers": tiers,
"free": tiers.get("free", 0),
"paid": tiers.get("paid", 0),
"enterprise": tiers.get("enterprise", 0),
"paid_active": paid_by_tier + credit_active,
"credit_active": credit_active,
"signups_7d": signups_7d,
"signups_30d": signups_30d,
"active_30d": active_30d,
"sync_enabled": sync_enabled,
"referrals_total": referrals_total,
"referrals_converted": referrals_converted,
}
async def list_users(
session: AsyncSession,
q: str | None = None,
limit: int = 200,
offset: int = 0,
) -> tuple[list[dict], int]:
"""User rows for the table view, newest first. Optional ``q`` filters by
email substring. Returns (rows, total_matching)."""
stmt = select(User)
count_stmt = select(func.count(User.id))
if q:
like = f"%{q.strip()}%"
stmt = stmt.where(User.email.like(like))
count_stmt = count_stmt.where(User.email.like(like))
total = await session.scalar(count_stmt) or 0
users = (await session.execute(
stmt.order_by(User.created_at.desc()).limit(limit).offset(offset)
)).scalars().all()
if not users:
return [], total
ids = [u.id for u in users]
# One grouped query each for the per-user badges, merged in Python —
# avoids an N+1 without a wide join fan-out.
sync_ids = set((await session.execute(
select(PortfolioSync.user_id).where(PortfolioSync.user_id.in_(ids))
)).scalars().all())
ref_counts = dict((await session.execute(
select(Referral.referrer_user_id, func.count(Referral.id))
.where(Referral.referrer_user_id.in_(ids))
.group_by(Referral.referrer_user_id)
)).all())
rows: list[dict] = []
for u in users:
ps = paid_status(u)
rows.append({
"id": u.id,
"email": u.email,
"tier": u.tier,
"paid_active": ps.active,
"paid_source": ps.source,
"credit_days": ps.days_remaining,
"created_at": u.created_at,
"last_login_at": u.last_login_at,
"lang": u.lang,
"has_sync": u.id in sync_ids,
"referrals": ref_counts.get(u.id, 0),
"on_stripe": bool(u.stripe_customer_id),
"on_polar": bool(u.polar_customer_id),
"trialing": u.stripe_trial_end_at is not None,
})
return rows, total
async def user_detail(session: AsyncSession, user_id: int) -> dict | None:
"""Everything the console shows for one user: the row itself, computed
payment status, billing linkage, and joined activity history."""
user = await session.get(User, user_id)
if user is None:
return None
ps = paid_status(user)
referred_by = None
if user.referred_by_user_id:
ref = await session.get(User, user.referred_by_user_id)
referred_by = {"id": ref.id, "email": ref.email} if ref else None
# Referrals this user sent (with conversion state).
sent = (await session.execute(
select(Referral, User.email)
.join(User, User.id == Referral.referred_user_id)
.where(Referral.referrer_user_id == user_id)
.order_by(Referral.created_at.desc())
)).all()
referrals_sent = [{
"referred_email": email,
"created_at": r.created_at,
"converted_at": r.converted_at,
} for r, email in sent]
# Digest email delivery log (most recent first, capped).
email_rows = (await session.execute(
select(EmailSend).where(EmailSend.user_id == user_id)
.order_by(EmailSend.sent_at.desc()).limit(25)
)).scalars().all()
emails = [{
"kind": e.kind, "sent_at": e.sent_at,
"status": e.status, "error": e.error,
} for e in email_rows]
# Legal acknowledgements (evidentiary audit trail).
ack_rows = (await session.execute(
select(UserAcknowledgement).where(UserAcknowledgement.user_id == user_id)
.order_by(UserAcknowledgement.accepted_at.desc())
)).scalars().all()
acks = [{
"version": a.version, "lang": a.lang, "accepted_at": a.accepted_at,
} for a in ack_rows]
# Feedback votes cast on strategic logs.
fb_rows = (await session.execute(
select(StrategicLogFeedback.vote, func.count(StrategicLogFeedback.id))
.where(StrategicLogFeedback.user_id == user_id)
.group_by(StrategicLogFeedback.vote)
)).all()
feedback = {vote: n for vote, n in fb_rows}
sync = await session.get(PortfolioSync, user_id)
return {
"user": user,
"paid": ps,
"referred_by": referred_by,
"referrals_sent": referrals_sent,
"emails": emails,
"acks": acks,
"feedback": feedback,
"sync": {
"enabled": sync is not None,
"updated_at": sync.updated_at if sync else None,
"version": sync.version if sync else None,
},
}
async def db_stats(session: AsyncSession) -> dict:
"""Per-table size and row estimates for the current schema.
On MariaDB this reads ``information_schema.tables`` (row counts are the
engine's estimate for InnoDB, sizes are exact). On sqlite (the test
backend) there is no such view, so it falls back to real ``COUNT(*)``
per table with sizes reported as 0.
"""
dialect = session.bind.dialect.name
if dialect == "sqlite":
from app.db import Base
tables = []
for tbl in Base.metadata.sorted_tables:
n = await session.scalar(select(func.count()).select_from(tbl)) or 0
tables.append({
"name": tbl.name, "rows": n,
"data_bytes": 0, "index_bytes": 0, "total_bytes": 0, "pct": 0.0,
})
tables.sort(key=lambda t: t["rows"], reverse=True)
return {"tables": tables, "total_bytes": 0, "total_rows": sum(t["rows"] for t in tables)}
rows = (await session.execute(text(
"SELECT table_name, table_rows, data_length, index_length "
"FROM information_schema.tables "
"WHERE table_schema = DATABASE() "
"ORDER BY (data_length + index_length) DESC"
))).all()
tables = []
total_bytes = 0
total_rows = 0
for name, tr, data_len, idx_len in rows:
data_len = int(data_len or 0)
idx_len = int(idx_len or 0)
tot = data_len + idx_len
total_bytes += tot
total_rows += int(tr or 0)
tables.append({
"name": name, "rows": int(tr or 0),
"data_bytes": data_len, "index_bytes": idx_len, "total_bytes": tot,
})
for t in tables:
t["pct"] = round(100.0 * t["total_bytes"] / total_bytes, 1) if total_bytes else 0.0
return {"tables": tables, "total_bytes": total_bytes, "total_rows": total_rows}