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:
parent
411094d7b8
commit
83ffa7dbf8
6 changed files with 246 additions and 22 deletions
96
app/cli.py
96
app/cli.py
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue