admin: front console via NPM + add purge-test-users CLI

Networking: the superadmin console now mirrors `app` instead of a
loopback-only host port. Base compose drops the host port; the dev
override binds 127.0.0.1:8091; the prod overlay joins the `intranet`
network and listens on :80 with --proxy-headers so NPM can proxy it.

CLI: add `purge-test-users` (dry-run by default, --commit to delete,
--keep allow-list defaulting to the real accounts). Deletes child rows
explicitly (DB-agnostic) plus email-keyed OTPs, so smoke-test signups
that were pointed at prod can be cleaned repeatably instead of via
ad-hoc SQL. Covered by 6 new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-07-27 18:54:26 +02:00
parent 411094d7b8
commit 83ffa7dbf8
6 changed files with 246 additions and 22 deletions

View file

@ -146,6 +146,90 @@ async def send_test_digest(email: str, kind: str) -> int:
return 0
# The genuinely-real accounts. Everything else in the users table is a
# leftover from smoke/E2E signup tests that were pointed at the live site
# (OTP delivered to the @gilest.ro catch-all). Kept here so the purge is an
# explicit allow-list — deny by default — rather than pattern-matching on
# test prefixes that a future test run might not follow.
DEFAULT_KEEP_EMAILS = (
"giorgio@gilest.ro",
"giorgio.gilestro@gmail.com",
"ilariodamato@hotmail.com",
)
async def purge_test_users(keep: list[str], commit: bool) -> int:
"""Delete every user whose email is not in `keep`, cascading to child
rows. Dry-run by default: prints exactly what would go and changes
nothing unless `commit` is True.
Deletes child rows explicitly (rather than leaning on DB-level ON DELETE
CASCADE) so behaviour is identical on MariaDB and the sqlite test DB.
"""
from sqlalchemy import delete, or_, update
from app.models import (
EmailOTP, EmailSend, PortfolioSync, Referral,
StrategicLogFeedback, User, UserAcknowledgement,
)
keep_set = {e.strip().lower() for e in keep if e.strip()}
factory = get_session_factory()
async with factory() as session:
rows = (await session.execute(
select(User.id, User.email, User.tier, User.created_at)
.order_by(User.id)
)).all()
victims = [r for r in rows if (r.email or "").lower() not in keep_set]
kept = [r for r in rows if (r.email or "").lower() in keep_set]
print(f"users total: {len(rows)} keep: {len(kept)} "
f"to delete: {len(victims)}")
print("\nKEEP:")
for r in kept:
print(f" [{r.id}] {r.email} ({r.tier})")
print("\nDELETE:" if victims else "\nDELETE: (none)")
for r in victims:
print(f" [{r.id}] {r.email} ({r.tier}) {r.created_at}")
if not victims:
print("\nnothing to do.")
return 0
if not commit:
print(f"\nDRY RUN — nothing deleted. "
f"Re-run with --commit to remove these {len(victims)} "
f"account(s).")
return 0
ids = [r.id for r in victims]
emails = [r.email for r in victims if r.email]
# Child rows first (explicit; order matters without cascade).
await session.execute(delete(StrategicLogFeedback)
.where(StrategicLogFeedback.user_id.in_(ids)))
await session.execute(delete(EmailSend)
.where(EmailSend.user_id.in_(ids)))
await session.execute(delete(PortfolioSync)
.where(PortfolioSync.user_id.in_(ids)))
await session.execute(delete(UserAcknowledgement)
.where(UserAcknowledgement.user_id.in_(ids)))
await session.execute(delete(Referral).where(or_(
Referral.referrer_user_id.in_(ids),
Referral.referred_user_id.in_(ids),
)))
# Self-referential FK on survivors that pointed at a victim.
await session.execute(update(User)
.where(User.referred_by_user_id.in_(ids))
.values(referred_by_user_id=None))
# OTPs are keyed by email, not user_id — no FK to cascade.
if emails:
await session.execute(delete(EmailOTP)
.where(EmailOTP.email.in_(emails)))
result = await session.execute(delete(User).where(User.id.in_(ids)))
await session.commit()
print(f"\ndeleted {result.rowcount} user(s) and their child rows.")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="app.cli", description="Cassandra admin CLI")
sub = p.add_subparsers(dest="cmd", required=True)
@ -165,6 +249,15 @@ def build_parser() -> argparse.ArgumentParser:
t.add_argument("email")
t.add_argument("kind", choices=("daily", "weekly"))
pu = sub.add_parser(
"purge-test-users",
help="Delete all users except an allow-list (dry-run unless --commit)")
pu.add_argument(
"--keep", action="append", metavar="EMAIL", default=None,
help="Email to keep; repeatable. Defaults to the known real accounts.")
pu.add_argument("--commit", action="store_true",
help="Actually delete. Without this it is a dry run.")
return p
@ -181,6 +274,9 @@ async def _dispatch(args) -> int:
return await show_status(args.email)
if args.cmd == "send-test-digest":
return await send_test_digest(args.email, args.kind)
if args.cmd == "purge-test-users":
keep = args.keep if args.keep else list(DEFAULT_KEEP_EMAILS)
return await purge_test_users(keep, args.commit)
return 2
finally:
await get_engine().dispose()