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:
parent
8946dee2e0
commit
411094d7b8
20 changed files with 1143 additions and 1 deletions
|
|
@ -16,6 +16,13 @@ OPENROUTER_API_KEY= # OpenRouter (AI log generation)
|
||||||
# --- App ---
|
# --- App ---
|
||||||
CASSANDRA_TOKEN= # Bearer token required if set; LAN-only no-auth if empty
|
CASSANDRA_TOKEN= # Bearer token required if set; LAN-only no-auth if empty
|
||||||
CASSANDRA_PORT=8000
|
CASSANDRA_PORT=8000
|
||||||
|
|
||||||
|
# --- Superadmin console (internal-only, separate `admin` container) ---
|
||||||
|
# Password for the read-only operator dashboard at 127.0.0.1:8091 (reach it
|
||||||
|
# via `ssh -L 8091:localhost:8091 <vps>`). Empty = console refuses every
|
||||||
|
# login (closed by default). Set a strong value in prod.
|
||||||
|
ADMIN_CONSOLE_PASSWORD=
|
||||||
|
ADMIN_CONSOLE_SESSION_SECRET= # cookie signing key; falls back to CASSANDRA_SESSION_SECRET/TOKEN if empty
|
||||||
CASSANDRA_BASE_CURRENCY=GBP
|
CASSANDRA_BASE_CURRENCY=GBP
|
||||||
CASSANDRA_ANCHOR_DATE=2026-03-04 # YYYY-MM-DD; used by market_pulse anchor column
|
CASSANDRA_ANCHOR_DATE=2026-03-04 # YYYY-MM-DD; used by market_pulse anchor column
|
||||||
CASSANDRA_MOCK=0 # 1 = serve canned fixtures, skip live APIs
|
CASSANDRA_MOCK=0 # 1 = serve canned fixtures, skip live APIs
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,12 @@ RUN apt-get update \
|
||||||
COPY --from=builder /opt/venv /opt/venv
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
|
COPY admin ./admin
|
||||||
COPY alembic ./alembic
|
COPY alembic ./alembic
|
||||||
COPY alembic.ini ./
|
COPY alembic.ini ./
|
||||||
|
|
||||||
# Default command is the web app; scheduler container overrides via `command:`.
|
# Default command is the web app; the scheduler and admin-console containers
|
||||||
|
# override via `command:` (see docker-compose.yml).
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
|
|
||||||
|
|
@ -57,6 +59,7 @@ COPY --from=builder /opt/venv /opt/venv
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pyproject.toml requirements.lock ./
|
COPY pyproject.toml requirements.lock ./
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
|
COPY admin ./admin
|
||||||
COPY alembic ./alembic
|
COPY alembic ./alembic
|
||||||
COPY alembic.ini ./
|
COPY alembic.ini ./
|
||||||
# tests/ is excluded by .dockerignore (prod-correct: never bake tests into
|
# tests/ is excluded by .dockerignore (prod-correct: never bake tests into
|
||||||
|
|
|
||||||
50
admin/README.md
Normal file
50
admin/README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Superadmin console
|
||||||
|
|
||||||
|
An **independent, internal-only** web service for the operator: user list with
|
||||||
|
per-user history + payment status, and database usage stats. Runs in its own
|
||||||
|
container (`admin` service) off the same image as the main app, reusing
|
||||||
|
`app.db` + `app.models`, but it is a separate FastAPI app (`admin.main:app`)
|
||||||
|
that **never** runs migrations or the scheduler and only ever issues `SELECT`s.
|
||||||
|
|
||||||
|
## Access model
|
||||||
|
|
||||||
|
- Bound to **`127.0.0.1:8091`** on the host — never exposed publicly, not on
|
||||||
|
the `intranet`/NPM network. Reach it over an SSH tunnel:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh -L 8091:localhost:8091 <vps>
|
||||||
|
# then open http://localhost:8091
|
||||||
|
```
|
||||||
|
|
||||||
|
- Gated by a single shared password, `ADMIN_CONSOLE_PASSWORD` (in `.env`).
|
||||||
|
Empty password ⇒ every login is refused (closed by default). The login sets
|
||||||
|
a 12-hour signed cookie (`admin_console_session`).
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
- `/` — overview: totals, tier split, paid-active, signups 7/30d, sync count,
|
||||||
|
referral conversions, newest users.
|
||||||
|
- `/users` — full user table with email search + paging.
|
||||||
|
- `/users/{id}` — one user: account, computed paid status, Stripe/Polar
|
||||||
|
linkage, cloud-sync state, referrals sent, feedback votes, digest-email log,
|
||||||
|
legal acknowledgements.
|
||||||
|
- `/db` — per-table row estimates and on-disk size (from
|
||||||
|
`information_schema.tables`), with each table's share of total.
|
||||||
|
|
||||||
|
## Deploy (this host runs prod)
|
||||||
|
|
||||||
|
Adding a **new** service means `up -d` (a plain `restart` won't create it).
|
||||||
|
Do NOT run a bare `docker compose up` on this host — always pass the prod
|
||||||
|
overlay:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. set ADMIN_CONSOLE_PASSWORD in .env
|
||||||
|
# 2. build + create just the admin container (leaves app/scheduler/db running)
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build admin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_admin_console.py -v
|
||||||
|
```
|
||||||
9
admin/__init__.py
Normal file
9
admin/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""Superadmin console — an independent, internal-only web service.
|
||||||
|
|
||||||
|
Runs in its own container (see the `admin` service in docker-compose.yml),
|
||||||
|
bound to 127.0.0.1 on the VPS and reached over an SSH tunnel. It reuses the
|
||||||
|
main app's ORM models and DB engine (`app.db`, `app.models`) but is a wholly
|
||||||
|
separate FastAPI app: it never imports `app.main`, so it does NOT run Alembic
|
||||||
|
migrations or the app's lifespan. Every DB access is a plain SELECT — the
|
||||||
|
console never writes.
|
||||||
|
"""
|
||||||
71
admin/auth.py
Normal file
71
admin/auth.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Password gate for the superadmin console.
|
||||||
|
|
||||||
|
A single shared password (``ADMIN_CONSOLE_PASSWORD``) is exchanged at
|
||||||
|
``/login`` for a signed, time-limited session cookie. There is no user
|
||||||
|
identity here — the console has exactly one principal, "the operator".
|
||||||
|
|
||||||
|
The cookie is signed with itsdangerous using ``ADMIN_CONSOLE_SESSION_SECRET``
|
||||||
|
(falling back to the main app's session secret / token), mirroring the scheme
|
||||||
|
in ``app.auth`` but under a distinct salt so an app session cookie can never
|
||||||
|
be replayed against the console and vice-versa.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request, status
|
||||||
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
SESSION_COOKIE_NAME = "admin_console_session"
|
||||||
|
SESSION_TTL_SECONDS = 12 * 60 * 60 # 12 hours — re-auth daily-ish.
|
||||||
|
_SALT = "admin-console-session-v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _serializer() -> URLSafeTimedSerializer:
|
||||||
|
s = get_settings()
|
||||||
|
secret = (
|
||||||
|
s.ADMIN_CONSOLE_SESSION_SECRET
|
||||||
|
or s.CASSANDRA_SESSION_SECRET
|
||||||
|
or s.CASSANDRA_TOKEN
|
||||||
|
or "dev-insecure-secret"
|
||||||
|
)
|
||||||
|
return URLSafeTimedSerializer(secret, salt=_SALT)
|
||||||
|
|
||||||
|
|
||||||
|
def check_password(provided: str) -> bool:
|
||||||
|
"""Constant-time compare against ``ADMIN_CONSOLE_PASSWORD``. Returns
|
||||||
|
False when the password is unset — the console is closed by default so a
|
||||||
|
fresh deploy can't be logged into without an explicit password."""
|
||||||
|
expected = get_settings().ADMIN_CONSOLE_PASSWORD
|
||||||
|
if not expected:
|
||||||
|
return False
|
||||||
|
return secrets.compare_digest(provided.encode(), expected.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def sign_session() -> str:
|
||||||
|
"""Signed value proving a successful password login. Carries no identity
|
||||||
|
beyond a version marker — the console has a single principal."""
|
||||||
|
return _serializer().dumps({"v": 1})
|
||||||
|
|
||||||
|
|
||||||
|
def verify_session(cookie: str) -> bool:
|
||||||
|
try:
|
||||||
|
data = _serializer().loads(cookie, max_age=SESSION_TTL_SECONDS)
|
||||||
|
return data.get("v") == 1
|
||||||
|
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(request: Request) -> None:
|
||||||
|
"""FastAPI dependency guarding every console page. Valid cookie → passes;
|
||||||
|
otherwise 303 → /login (browser) so the operator lands on the form."""
|
||||||
|
cookie = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if cookie and verify_session(cookie):
|
||||||
|
return
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
detail="Login required",
|
||||||
|
headers={"Location": "/login"},
|
||||||
|
)
|
||||||
140
admin/main.py
Normal file
140
admin/main.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""Superadmin console FastAPI app.
|
||||||
|
|
||||||
|
Independent of ``app.main``: it wires only its own routes and reuses
|
||||||
|
``app.db``'s session factory for read-only queries. No lifespan, no
|
||||||
|
migrations, no scheduler.
|
||||||
|
|
||||||
|
Run: uvicorn admin.main:app --host 0.0.0.0 --port 8000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, Form, Query, Request
|
||||||
|
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from admin import auth, queries
|
||||||
|
from app.db import get_session
|
||||||
|
|
||||||
|
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
|
||||||
|
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_bytes(n: int | None) -> str:
|
||||||
|
if not n:
|
||||||
|
return "0 B"
|
||||||
|
units = ["B", "KB", "MB", "GB", "TB"]
|
||||||
|
size = float(n)
|
||||||
|
for u in units:
|
||||||
|
if size < 1024 or u == units[-1]:
|
||||||
|
return f"{size:,.0f} {u}" if u == "B" else f"{size:,.1f} {u}"
|
||||||
|
size /= 1024
|
||||||
|
return f"{n} B"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_dt(dt: datetime | None) -> str:
|
||||||
|
if dt is None:
|
||||||
|
return "—"
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
|
||||||
|
|
||||||
|
templates.env.filters["bytes"] = _fmt_bytes
|
||||||
|
templates.env.filters["dt"] = _fmt_dt
|
||||||
|
|
||||||
|
app = FastAPI(title="read.markets superadmin console", docs_url=None, redoc_url=None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz", response_class=PlainTextResponse, include_in_schema=False)
|
||||||
|
async def healthz() -> str:
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def login_form(request: Request):
|
||||||
|
# Already signed in? Skip the form.
|
||||||
|
cookie = request.cookies.get(auth.SESSION_COOKIE_NAME)
|
||||||
|
if cookie and auth.verify_session(cookie):
|
||||||
|
return RedirectResponse("/", status_code=303)
|
||||||
|
return templates.TemplateResponse(request, "login.html", {"error": None})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/login", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def login_submit(request: Request, password: str = Form(...)):
|
||||||
|
if not auth.check_password(password):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "login.html", {"error": "Incorrect password."},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
resp = RedirectResponse("/", status_code=303)
|
||||||
|
resp.set_cookie(
|
||||||
|
auth.SESSION_COOKIE_NAME,
|
||||||
|
auth.sign_session(),
|
||||||
|
max_age=auth.SESSION_TTL_SECONDS,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
# secure=False: the console is served over http on a localhost SSH
|
||||||
|
# tunnel, never public TLS. A secure cookie would never be sent.
|
||||||
|
secure=False,
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logout", include_in_schema=False)
|
||||||
|
async def logout():
|
||||||
|
resp = RedirectResponse("/login", status_code=303)
|
||||||
|
resp.delete_cookie(auth.SESSION_COOKIE_NAME)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse, include_in_schema=False,
|
||||||
|
dependencies=[Depends(auth.require_admin)])
|
||||||
|
async def overview(request: Request, session: AsyncSession = Depends(get_session)):
|
||||||
|
stats = await queries.overview_stats(session)
|
||||||
|
recent, _ = await queries.list_users(session, limit=10)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "overview.html", {"stats": stats, "recent": recent},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/users", response_class=HTMLResponse, include_in_schema=False,
|
||||||
|
dependencies=[Depends(auth.require_admin)])
|
||||||
|
async def users_list(
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
q: str | None = Query(default=None),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
):
|
||||||
|
per_page = 200
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
rows, total = await queries.list_users(session, q=q, limit=per_page, offset=offset)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "users.html",
|
||||||
|
{"rows": rows, "total": total, "q": q or "",
|
||||||
|
"page": page, "per_page": per_page},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/users/{user_id}", response_class=HTMLResponse, include_in_schema=False,
|
||||||
|
dependencies=[Depends(auth.require_admin)])
|
||||||
|
async def user_detail(
|
||||||
|
request: Request, user_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
detail = await queries.user_detail(session, user_id)
|
||||||
|
if detail is None:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "not_found.html", {"user_id": user_id}, status_code=404,
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(request, "user_detail.html", {"d": detail})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/db", response_class=HTMLResponse, include_in_schema=False,
|
||||||
|
dependencies=[Depends(auth.require_admin)])
|
||||||
|
async def db_page(request: Request, session: AsyncSession = Depends(get_session)):
|
||||||
|
stats = await queries.db_stats(session)
|
||||||
|
return templates.TemplateResponse(request, "db.html", {"stats": stats})
|
||||||
266
admin/queries.py
Normal file
266
admin/queries.py
Normal 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}
|
||||||
80
admin/templates/base.html
Normal file
80
admin/templates/base.html
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>{% block title %}superadmin{% endblock %} · read.markets</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg:#0d1117; --panel:#161b22; --border:#30363d; --fg:#c9d1d9;
|
||||||
|
--muted:#8b949e; --accent:#58a6ff; --good:#3fb950; --warn:#d29922;
|
||||||
|
--bad:#f85149; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||||
|
}
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; background:var(--bg); color:var(--fg);
|
||||||
|
font:14px/1.5 var(--mono); }
|
||||||
|
a { color:var(--accent); text-decoration:none; }
|
||||||
|
a:hover { text-decoration:underline; }
|
||||||
|
header { display:flex; align-items:center; gap:1.5rem; padding:.75rem 1.25rem;
|
||||||
|
background:var(--panel); border-bottom:1px solid var(--border); }
|
||||||
|
header .brand { font-weight:700; color:#fff; }
|
||||||
|
header nav { display:flex; gap:1rem; }
|
||||||
|
header .spacer { flex:1; }
|
||||||
|
main { padding:1.25rem; max-width:1200px; margin:0 auto; }
|
||||||
|
h1 { font-size:1.25rem; margin:0 0 1rem; }
|
||||||
|
h2 { font-size:1rem; color:var(--muted); text-transform:uppercase;
|
||||||
|
letter-spacing:.05em; margin:1.5rem 0 .5rem; }
|
||||||
|
.cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr));
|
||||||
|
gap:.75rem; }
|
||||||
|
.card { background:var(--panel); border:1px solid var(--border);
|
||||||
|
border-radius:6px; padding:.75rem 1rem; }
|
||||||
|
.card .n { font-size:1.6rem; font-weight:700; color:#fff; }
|
||||||
|
.card .l { color:var(--muted); font-size:.8rem; }
|
||||||
|
table { width:100%; border-collapse:collapse; margin-top:.5rem; }
|
||||||
|
th,td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--border);
|
||||||
|
white-space:nowrap; }
|
||||||
|
th { color:var(--muted); font-weight:600; font-size:.8rem;
|
||||||
|
text-transform:uppercase; letter-spacing:.04em; }
|
||||||
|
tr:hover td { background:#1c2230; }
|
||||||
|
.pill { display:inline-block; padding:.05rem .5rem; border-radius:999px;
|
||||||
|
font-size:.75rem; border:1px solid var(--border); }
|
||||||
|
.pill.good { color:var(--good); border-color:#238636; }
|
||||||
|
.pill.warn { color:var(--warn); border-color:#9e6a03; }
|
||||||
|
.pill.bad { color:var(--bad); border-color:#a1281f; }
|
||||||
|
.pill.mute { color:var(--muted); }
|
||||||
|
.muted { color:var(--muted); }
|
||||||
|
.bar { height:8px; background:#21262d; border-radius:4px; overflow:hidden;
|
||||||
|
min-width:80px; }
|
||||||
|
.bar > span { display:block; height:100%; background:var(--accent); }
|
||||||
|
form.search { margin:.5rem 0; }
|
||||||
|
input[type=text],input[type=password] {
|
||||||
|
background:#0d1117; border:1px solid var(--border); color:var(--fg);
|
||||||
|
padding:.4rem .6rem; border-radius:6px; font:inherit; }
|
||||||
|
button { background:#238636; color:#fff; border:0; padding:.45rem .9rem;
|
||||||
|
border-radius:6px; font:inherit; cursor:pointer; }
|
||||||
|
button:hover { background:#2ea043; }
|
||||||
|
.kv { display:grid; grid-template-columns:180px 1fr; gap:.25rem 1rem; }
|
||||||
|
.kv dt { color:var(--muted); }
|
||||||
|
.kv dd { margin:0; }
|
||||||
|
.login { max-width:340px; margin:12vh auto; background:var(--panel);
|
||||||
|
border:1px solid var(--border); border-radius:8px; padding:1.5rem; }
|
||||||
|
.err { color:var(--bad); margin:.5rem 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if show_nav|default(true) %}
|
||||||
|
<header>
|
||||||
|
<span class="brand">read.markets · superadmin</span>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Overview</a>
|
||||||
|
<a href="/users">Users</a>
|
||||||
|
<a href="/db">Database</a>
|
||||||
|
</nav>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<a href="/logout">Log out</a>
|
||||||
|
</header>
|
||||||
|
{% endif %}
|
||||||
|
<main>{% block body %}{% endblock %}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
admin/templates/db.html
Normal file
31
admin/templates/db.html
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}database{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>Database usage</h1>
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card"><div class="n">{{ stats.total_bytes|bytes }}</div><div class="l">Total size</div></div>
|
||||||
|
<div class="card"><div class="n">{{ "{:,}".format(stats.total_rows) }}</div><div class="l">Total rows (est.)</div></div>
|
||||||
|
<div class="card"><div class="n">{{ stats.tables|length }}</div><div class="l">Tables</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Per table</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Table</th><th>Rows (est.)</th><th>Data</th><th>Index</th><th>Total</th><th style="width:160px">Share</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in stats.tables %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ t.name }}</td>
|
||||||
|
<td>{{ "{:,}".format(t.rows) }}</td>
|
||||||
|
<td class="muted">{{ t.data_bytes|bytes }}</td>
|
||||||
|
<td class="muted">{{ t.index_bytes|bytes }}</td>
|
||||||
|
<td>{{ t.total_bytes|bytes }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="bar"><span style="width:{{ t.pct }}%"></span></div>
|
||||||
|
<span class="muted">{{ t.pct }}%</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted">Row counts are the storage engine's estimate for InnoDB; sizes are exact on-disk bytes.</p>
|
||||||
|
{% endblock %}
|
||||||
14
admin/templates/login.html
Normal file
14
admin/templates/login.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% set show_nav = false %}
|
||||||
|
{% block title %}login{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="login">
|
||||||
|
<h1>Superadmin</h1>
|
||||||
|
{% if error %}<div class="err">{{ error }}</div>{% endif %}
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<p><input type="password" name="password" placeholder="Password"
|
||||||
|
autofocus autocomplete="current-password" style="width:100%"></p>
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
6
admin/templates/not_found.html
Normal file
6
admin/templates/not_found.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}not found{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>User #{{ user_id }} not found</h1>
|
||||||
|
<p><a href="/users">← back to users</a></p>
|
||||||
|
{% endblock %}
|
||||||
22
admin/templates/overview.html
Normal file
22
admin/templates/overview.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}overview{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>Overview</h1>
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card"><div class="n">{{ stats.total_users }}</div><div class="l">Total users</div></div>
|
||||||
|
<div class="card"><div class="n">{{ stats.paid_active }}</div><div class="l">Paid-active</div></div>
|
||||||
|
<div class="card"><div class="n">{{ stats.free }}</div><div class="l">Free tier</div></div>
|
||||||
|
<div class="card"><div class="n">{{ stats.paid }}</div><div class="l">Paid tier</div></div>
|
||||||
|
<div class="card"><div class="n">{{ stats.enterprise }}</div><div class="l">Enterprise</div></div>
|
||||||
|
<div class="card"><div class="n">{{ stats.credit_active }}</div><div class="l">On credit</div></div>
|
||||||
|
<div class="card"><div class="n">{{ stats.signups_7d }}</div><div class="l">Signups · 7d</div></div>
|
||||||
|
<div class="card"><div class="n">{{ stats.signups_30d }}</div><div class="l">Signups · 30d</div></div>
|
||||||
|
<div class="card"><div class="n">{{ stats.active_30d }}</div><div class="l">Active · 30d</div></div>
|
||||||
|
<div class="card"><div class="n">{{ stats.sync_enabled }}</div><div class="l">Cloud-sync on</div></div>
|
||||||
|
<div class="card"><div class="n">{{ stats.referrals_converted }}/{{ stats.referrals_total }}</div><div class="l">Referrals conv.</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Newest users</h2>
|
||||||
|
{% include "partials_users_table.html" %}
|
||||||
|
<p><a href="/users">All users →</a></p>
|
||||||
|
{% endblock %}
|
||||||
39
admin/templates/partials_users_table.html
Normal file
39
admin/templates/partials_users_table.html
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{# expects `recent` (overview) or `rows` (users list) — normalise to `rows` #}
|
||||||
|
{% set rows = rows if rows is defined else recent %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th><th>Email</th><th>Tier</th><th>Paid</th><th>Created</th>
|
||||||
|
<th>Last login</th><th>Lang</th><th>Sync</th><th>Refs</th><th>Billing</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in rows %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ u.id }}</td>
|
||||||
|
<td><a href="/users/{{ u.id }}">{{ u.email }}</a></td>
|
||||||
|
<td>{{ u.tier }}</td>
|
||||||
|
<td>
|
||||||
|
{% if u.paid_active %}
|
||||||
|
<span class="pill good">yes{% if u.paid_source == 'credit' %} · {{ u.credit_days }}d{% endif %}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="pill mute">no</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if u.trialing %}<span class="pill warn">trial</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ u.created_at|dt }}</td>
|
||||||
|
<td class="muted">{{ u.last_login_at|dt }}</td>
|
||||||
|
<td>{{ u.lang }}</td>
|
||||||
|
<td>{% if u.has_sync %}<span class="pill good">on</span>{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||||
|
<td>{{ u.referrals }}</td>
|
||||||
|
<td>
|
||||||
|
{% if u.on_stripe %}<span class="pill">stripe</span>{% endif %}
|
||||||
|
{% if u.on_polar %}<span class="pill">polar</span>{% endif %}
|
||||||
|
{% if not u.on_stripe and not u.on_polar %}<span class="muted">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="10" class="muted">No users.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
73
admin/templates/user_detail.html
Normal file
73
admin/templates/user_detail.html
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% set u = d.user %}
|
||||||
|
{% block title %}{{ u.email }}{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<p class="muted"><a href="/users">← users</a></p>
|
||||||
|
<h1>{{ u.email }} <span class="muted">#{{ u.id }}</span></h1>
|
||||||
|
|
||||||
|
<h2>Account</h2>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Tier</dt><dd>{{ u.tier }}</dd>
|
||||||
|
<dt>Paid status</dt><dd>
|
||||||
|
{% if d.paid.active %}
|
||||||
|
<span class="pill good">active</span> via {{ d.paid.source }}
|
||||||
|
{% if d.paid.source == 'credit' %}· expires {{ d.paid.expires_at|dt }} ({{ d.paid.days_remaining }}d){% endif %}
|
||||||
|
{% else %}<span class="pill mute">inactive</span>{% endif %}
|
||||||
|
</dd>
|
||||||
|
<dt>Credit until</dt><dd>{{ u.credit_until|dt }}</dd>
|
||||||
|
<dt>Created</dt><dd>{{ u.created_at|dt }}</dd>
|
||||||
|
<dt>Last login</dt><dd>{{ u.last_login_at|dt }}</dd>
|
||||||
|
<dt>Language</dt><dd>{{ u.lang }}</dd>
|
||||||
|
<dt>Digest opt-in</dt><dd>{{ 'yes' if u.email_digest_opt_in else 'no' }}{% if u.digest_tone %} · {{ u.digest_tone }}{% endif %}</dd>
|
||||||
|
<dt>Referral code</dt><dd>{{ u.referral_code or '—' }}</dd>
|
||||||
|
<dt>Referred by</dt><dd>{% if d.referred_by %}<a href="/users/{{ d.referred_by.id }}">{{ d.referred_by.email }}</a>{% else %}—{% endif %}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h2>Billing linkage</h2>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Stripe customer</dt><dd>{{ u.stripe_customer_id or '—' }}</dd>
|
||||||
|
<dt>Stripe subscription</dt><dd>{{ u.stripe_subscription_id or '—' }}</dd>
|
||||||
|
<dt>Stripe trial ends</dt><dd>{{ u.stripe_trial_end_at|dt }}</dd>
|
||||||
|
<dt>Polar customer</dt><dd>{{ u.polar_customer_id or '—' }}</dd>
|
||||||
|
<dt>Polar subscription</dt><dd>{{ u.polar_subscription_id or '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h2>Cloud sync</h2>
|
||||||
|
{% if d.sync.enabled %}
|
||||||
|
<p><span class="pill good">enabled</span> · v{{ d.sync.version }} · updated {{ d.sync.updated_at|dt }}
|
||||||
|
<span class="muted">(contents are end-to-end encrypted — not readable here)</span></p>
|
||||||
|
{% else %}<p class="muted">Not enabled.</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Referrals sent ({{ d.referrals_sent|length }})</h2>
|
||||||
|
{% if d.referrals_sent %}
|
||||||
|
<table><thead><tr><th>Referred</th><th>Sent</th><th>Converted</th></tr></thead><tbody>
|
||||||
|
{% for r in d.referrals_sent %}
|
||||||
|
<tr><td>{{ r.referred_email }}</td><td class="muted">{{ r.created_at|dt }}</td>
|
||||||
|
<td>{% if r.converted_at %}<span class="pill good">{{ r.converted_at|dt }}</span>{% else %}<span class="muted">pending</span>{% endif %}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody></table>
|
||||||
|
{% else %}<p class="muted">None.</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Feedback votes</h2>
|
||||||
|
<p>👍 {{ d.feedback.get('up', 0) }} · 👎 {{ d.feedback.get('down', 0) }}</p>
|
||||||
|
|
||||||
|
<h2>Digest emails (last 25)</h2>
|
||||||
|
{% if d.emails %}
|
||||||
|
<table><thead><tr><th>Kind</th><th>Sent</th><th>Status</th><th>Error</th></tr></thead><tbody>
|
||||||
|
{% for e in d.emails %}
|
||||||
|
<tr><td>{{ e.kind }}</td><td class="muted">{{ e.sent_at|dt }}</td>
|
||||||
|
<td>{% if e.status == 'sent' %}<span class="pill good">sent</span>{% elif e.status == 'error' %}<span class="pill bad">error</span>{% else %}<span class="pill warn">{{ e.status }}</span>{% endif %}</td>
|
||||||
|
<td class="muted">{{ e.error or '' }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody></table>
|
||||||
|
{% else %}<p class="muted">None.</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Legal acknowledgements</h2>
|
||||||
|
{% if d.acks %}
|
||||||
|
<table><thead><tr><th>Version</th><th>Lang</th><th>Accepted</th></tr></thead><tbody>
|
||||||
|
{% for a in d.acks %}
|
||||||
|
<tr><td>v{{ a.version }}</td><td>{{ a.lang }}</td><td class="muted">{{ a.accepted_at|dt }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody></table>
|
||||||
|
{% else %}<p class="muted">None recorded.</p>{% endif %}
|
||||||
|
{% endblock %}
|
||||||
20
admin/templates/users.html
Normal file
20
admin/templates/users.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}users{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<h1>Users <span class="muted">({{ total }})</span></h1>
|
||||||
|
<form class="search" method="get" action="/users">
|
||||||
|
<input type="text" name="q" value="{{ q }}" placeholder="filter by email…">
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
{% if q %}<a href="/users" style="margin-left:.5rem">clear</a>{% endif %}
|
||||||
|
</form>
|
||||||
|
{% include "partials_users_table.html" %}
|
||||||
|
|
||||||
|
{% set pages = (total // per_page) + (1 if total % per_page else 0) %}
|
||||||
|
{% if pages > 1 %}
|
||||||
|
<p class="muted">
|
||||||
|
Page {{ page }} / {{ pages }}
|
||||||
|
{% if page > 1 %}· <a href="/users?page={{ page-1 }}{% if q %}&q={{ q }}{% endif %}">prev</a>{% endif %}
|
||||||
|
{% if page < pages %}· <a href="/users?page={{ page+1 }}{% if q %}&q={{ q }}{% endif %}">next</a>{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -50,6 +50,17 @@ class Settings(BaseSettings):
|
||||||
# created. Phase A leaves this open so the operator can self-onboard.
|
# created. Phase A leaves this open so the operator can self-onboard.
|
||||||
CASSANDRA_SIGNUP_ENABLED: bool = True
|
CASSANDRA_SIGNUP_ENABLED: bool = True
|
||||||
|
|
||||||
|
# Superadmin console (independent container, internal-only — bound to
|
||||||
|
# 127.0.0.1 on the VPS, reached via SSH tunnel). Read-only operator
|
||||||
|
# dashboard: user list, per-user history/payment status, DB usage stats.
|
||||||
|
# A single shared password gates it; empty ADMIN_CONSOLE_PASSWORD makes
|
||||||
|
# every login attempt fail (the console is closed rather than open by
|
||||||
|
# default, so a fresh deploy can't accidentally expose it).
|
||||||
|
ADMIN_CONSOLE_PASSWORD: str = ""
|
||||||
|
# Signing secret for the console's session cookie. Falls back to
|
||||||
|
# CASSANDRA_SESSION_SECRET / CASSANDRA_TOKEN like the main app's cookie.
|
||||||
|
ADMIN_CONSOLE_SESSION_SECRET: str = ""
|
||||||
|
|
||||||
# SMTP for email OTP verification. If SMTP_SERVER is empty, OTP codes
|
# SMTP for email OTP verification. If SMTP_SERVER is empty, OTP codes
|
||||||
# are written to stdout instead of sent — convenient for local dev.
|
# are written to stdout instead of sent — convenient for local dev.
|
||||||
SMTP_SERVER: str = ""
|
SMTP_SERVER: str = ""
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,14 @@ services:
|
||||||
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra}
|
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra}
|
||||||
REDIS_URL: redis://readmarkets-redis-1:6379/0
|
REDIS_URL: redis://readmarkets-redis-1:6379/0
|
||||||
|
|
||||||
|
admin:
|
||||||
|
# Same DNS-collision reasoning as app/scheduler: use the project-prefixed
|
||||||
|
# container name for the DB. The console stays OFF the intranet network —
|
||||||
|
# it is internal-only (127.0.0.1:8091 host port from the base file), so it
|
||||||
|
# never needs to be reachable by NPM.
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@readmarkets-db-1:3306/${MARIADB_DATABASE:-cassandra}
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
intranet:
|
intranet:
|
||||||
external: true
|
external: true
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ services:
|
||||||
# on the next `run` without rebuilding the image.
|
# on the next `run` without rebuilding the image.
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
|
- ./admin:/app/admin
|
||||||
- ./tests:/app/tests
|
- ./tests:/app/tests
|
||||||
- ./alembic:/app/alembic
|
- ./alembic:/app/alembic
|
||||||
- ./alembic.ini:/app/alembic.ini:ro
|
- ./alembic.ini:/app/alembic.ini:ro
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,33 @@ services:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
# Superadmin console — independent read-only operator dashboard. Same
|
||||||
|
# image (reuses app.db/app.models) but runs admin.main:app instead of the
|
||||||
|
# public app, and NEVER runs migrations. Bound to 127.0.0.1 only: it is
|
||||||
|
# reached over an SSH tunnel, never exposed publicly (no intranet/NPM).
|
||||||
|
admin:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["uvicorn", "admin.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@db:3306/${MARIADB_DATABASE:-cassandra}
|
||||||
|
volumes:
|
||||||
|
- ./config:/app/config:ro
|
||||||
|
- ./app:/app/app
|
||||||
|
- ./admin:/app/admin
|
||||||
|
ports:
|
||||||
|
# Host-loopback only — access via `ssh -L 8091:localhost:8091 <vps>`.
|
||||||
|
- "127.0.0.1:8091:8000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
backup:
|
backup:
|
||||||
image: mariadb:11
|
image: mariadb:11
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
264
tests/test_admin_console.py
Normal file
264
tests/test_admin_console.py
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
"""Superadmin console — auth gate, read-only queries, and page wiring.
|
||||||
|
|
||||||
|
Runs against an in-memory-ish sqlite file (same pattern as the other API
|
||||||
|
tests): seed a handful of users + related rows, rebind app.db to the test
|
||||||
|
engine, then drive both the query layer directly and the FastAPI app via
|
||||||
|
TestClient.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(tmp_path):
|
||||||
|
"""Create schema + fixture rows; rebind app.db. Returns the factory."""
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app import db as db_mod
|
||||||
|
from app.db import Base, utcnow
|
||||||
|
from app.models import (
|
||||||
|
EmailSend, PortfolioSync, Referral, StrategicLogFeedback,
|
||||||
|
User, UserAcknowledgement,
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/admin.db")
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
db_mod._engine = engine
|
||||||
|
db_mod._session_factory = factory
|
||||||
|
|
||||||
|
now = utcnow()
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
async with factory() as s:
|
||||||
|
# 1: free, referrer
|
||||||
|
s.add(User(id=1, email="alice@x", tier="free", created_at=now,
|
||||||
|
last_login_at=now, referral_code="ALICE"))
|
||||||
|
# 2: paid subscriber via Stripe
|
||||||
|
s.add(User(id=2, email="bob@x", tier="paid", created_at=now,
|
||||||
|
last_login_at=now, stripe_customer_id="cus_1",
|
||||||
|
stripe_subscription_id="sub_1"))
|
||||||
|
# 3: free but credit-active, referred by alice, has sync/history
|
||||||
|
s.add(User(id=3, email="carol@x", tier="free", created_at=now,
|
||||||
|
credit_until=now + timedelta(days=30),
|
||||||
|
referred_by_user_id=1))
|
||||||
|
await s.flush()
|
||||||
|
s.add(PortfolioSync(user_id=3, outer_ciphertext=b"x",
|
||||||
|
outer_nonce=b"y", version=1,
|
||||||
|
created_at=now, updated_at=now))
|
||||||
|
s.add(EmailSend(user_id=3, kind="daily", sent_at=now, status="sent"))
|
||||||
|
s.add(UserAcknowledgement(user_id=3, version=2, lang="en",
|
||||||
|
accepted_at=now))
|
||||||
|
s.add(StrategicLogFeedback(log_id=1, user_id=3, vote="up",
|
||||||
|
created_at=now, updated_at=now))
|
||||||
|
s.add(Referral(referrer_user_id=1, referred_user_id=3,
|
||||||
|
created_at=now, converted_at=now))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
asyncio.run(_go())
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
# --- auth ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_check_password(monkeypatch):
|
||||||
|
from app.config import get_settings
|
||||||
|
from admin import auth
|
||||||
|
|
||||||
|
monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "s3cret")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
assert auth.check_password("s3cret") is True
|
||||||
|
assert auth.check_password("wrong") is False
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_password_empty_denies(monkeypatch):
|
||||||
|
from app.config import get_settings
|
||||||
|
from admin import auth
|
||||||
|
|
||||||
|
monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
# Empty password must reject everything — console closed by default.
|
||||||
|
assert auth.check_password("") is False
|
||||||
|
assert auth.check_password("anything") is False
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_round_trip(monkeypatch):
|
||||||
|
from app.config import get_settings
|
||||||
|
from admin import auth
|
||||||
|
|
||||||
|
monkeypatch.setenv("ADMIN_CONSOLE_SESSION_SECRET", "unit-test-secret")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
token = auth.sign_session()
|
||||||
|
assert auth.verify_session(token) is True
|
||||||
|
assert auth.verify_session("garbage") is False
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
# --- queries ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_overview_stats(tmp_path):
|
||||||
|
factory = _seed(tmp_path)
|
||||||
|
from admin import queries
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with factory() as s:
|
||||||
|
return await queries.overview_stats(s)
|
||||||
|
|
||||||
|
stats = asyncio.run(_go())
|
||||||
|
assert stats["total_users"] == 3
|
||||||
|
assert stats["free"] == 2
|
||||||
|
assert stats["paid"] == 1
|
||||||
|
# bob (tier paid) + carol (credit) both count as paid-active.
|
||||||
|
assert stats["paid_active"] == 2
|
||||||
|
assert stats["credit_active"] == 1
|
||||||
|
assert stats["sync_enabled"] == 1
|
||||||
|
assert stats["referrals_total"] == 1
|
||||||
|
assert stats["referrals_converted"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users(tmp_path):
|
||||||
|
factory = _seed(tmp_path)
|
||||||
|
from admin import queries
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with factory() as s:
|
||||||
|
return await queries.list_users(s)
|
||||||
|
|
||||||
|
rows, total = asyncio.run(_go())
|
||||||
|
assert total == 3
|
||||||
|
by_id = {r["id"]: r for r in rows}
|
||||||
|
assert by_id[2]["paid_active"] and by_id[2]["paid_source"] == "tier"
|
||||||
|
assert by_id[2]["on_stripe"] is True
|
||||||
|
assert by_id[3]["paid_active"] and by_id[3]["paid_source"] == "credit"
|
||||||
|
assert by_id[3]["has_sync"] is True
|
||||||
|
assert by_id[1]["referrals"] == 1
|
||||||
|
assert by_id[1]["paid_active"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_search(tmp_path):
|
||||||
|
factory = _seed(tmp_path)
|
||||||
|
from admin import queries
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with factory() as s:
|
||||||
|
return await queries.list_users(s, q="bob")
|
||||||
|
|
||||||
|
rows, total = asyncio.run(_go())
|
||||||
|
assert total == 1 and rows[0]["email"] == "bob@x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_detail(tmp_path):
|
||||||
|
factory = _seed(tmp_path)
|
||||||
|
from admin import queries
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with factory() as s:
|
||||||
|
return await queries.user_detail(s, 3)
|
||||||
|
|
||||||
|
d = asyncio.run(_go())
|
||||||
|
assert d is not None
|
||||||
|
assert d["user"].email == "carol@x"
|
||||||
|
assert d["paid"].active and d["paid"].source == "credit"
|
||||||
|
assert d["referred_by"]["email"] == "alice@x"
|
||||||
|
assert d["sync"]["enabled"] is True
|
||||||
|
assert d["feedback"].get("up") == 1
|
||||||
|
assert len(d["emails"]) == 1
|
||||||
|
assert len(d["acks"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_detail_missing(tmp_path):
|
||||||
|
factory = _seed(tmp_path)
|
||||||
|
from admin import queries
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with factory() as s:
|
||||||
|
return await queries.user_detail(s, 999)
|
||||||
|
|
||||||
|
assert asyncio.run(_go()) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_stats_sqlite_fallback(tmp_path):
|
||||||
|
factory = _seed(tmp_path)
|
||||||
|
from admin import queries
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with factory() as s:
|
||||||
|
return await queries.db_stats(s)
|
||||||
|
|
||||||
|
stats = asyncio.run(_go())
|
||||||
|
names = {t["name"] for t in stats["tables"]}
|
||||||
|
assert "users" in names
|
||||||
|
users_row = next(t for t in stats["tables"] if t["name"] == "users")
|
||||||
|
assert users_row["rows"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# --- app wiring ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _client(tmp_path, monkeypatch):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
_seed(tmp_path)
|
||||||
|
monkeypatch.setenv("ADMIN_CONSOLE_PASSWORD", "letmein")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
from admin.main import app
|
||||||
|
return TestClient(app, follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pages_require_login(tmp_path, monkeypatch):
|
||||||
|
client = _client(tmp_path, monkeypatch)
|
||||||
|
for path in ("/", "/users", "/users/1", "/db"):
|
||||||
|
r = client.get(path)
|
||||||
|
assert r.status_code == 303, path
|
||||||
|
assert r.headers["location"] == "/login"
|
||||||
|
get_settings_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_flow(tmp_path, monkeypatch):
|
||||||
|
client = _client(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
# Wrong password → 401, no cookie.
|
||||||
|
r = client.post("/login", data={"password": "nope"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert "admin_console_session" not in r.cookies
|
||||||
|
|
||||||
|
# Right password → 303 to / with a session cookie.
|
||||||
|
r = client.post("/login", data={"password": "letmein"})
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert r.headers["location"] == "/"
|
||||||
|
cookie = r.cookies.get("admin_console_session")
|
||||||
|
assert cookie
|
||||||
|
|
||||||
|
# Authenticated pages now load.
|
||||||
|
r = client.get("/", cookies={"admin_console_session": cookie})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "Overview" in r.text
|
||||||
|
|
||||||
|
r = client.get("/users", cookies={"admin_console_session": cookie})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "carol@x" in r.text
|
||||||
|
|
||||||
|
r = client.get("/users/3", cookies={"admin_console_session": cookie})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "carol@x" in r.text and "alice@x" in r.text
|
||||||
|
|
||||||
|
r = client.get("/db", cookies={"admin_console_session": cookie})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "users" in r.text
|
||||||
|
get_settings_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthz(tmp_path, monkeypatch):
|
||||||
|
client = _client(tmp_path, monkeypatch)
|
||||||
|
r = client.get("/healthz")
|
||||||
|
assert r.status_code == 200 and r.text == "ok"
|
||||||
|
get_settings_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_clear():
|
||||||
|
from app.config import get_settings
|
||||||
|
get_settings.cache_clear()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue