136 lines
4.7 KiB
Python
136 lines
4.7 KiB
Python
"""
|
|
Which tenants still need a real plan?
|
|
|
|
The D1 backfill put **every** tenant on `custom` and carried their existing
|
|
limits over as overrides. That was the right call — inferring a commercial fact
|
|
("their quota looks like Starter, so they must be on Starter") is how somebody
|
|
ends up on the wrong plan and finds out at renewal.
|
|
|
|
But it leaves work undone with nothing to show for it: a safe migration that
|
|
quietly parks everyone in a holding pen. This is the report that says so.
|
|
|
|
APP_ENV=rework python scripts/plan_review.py
|
|
|
|
Read-only. It prints, suggests, and changes nothing — moving a tenant onto a
|
|
plan is a commercial decision and belongs in the admin UI, made by somebody who
|
|
knows what was actually sold.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
os.environ.setdefault("APP_ENV", "rework")
|
|
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
GB = 1024 ** 3
|
|
|
|
|
|
def human(key: str, value) -> str:
|
|
if value is None:
|
|
return "-"
|
|
if value == -1:
|
|
return "unlimited"
|
|
if key.endswith("_bytes"):
|
|
return f"{value / GB:.0f} GB" if value >= GB else f"{value / 1024 ** 2:.0f} MB"
|
|
return f"{value:,}"
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--database-url", help="Override the URL from settings.")
|
|
args = parser.parse_args()
|
|
|
|
from sqlalchemy import create_engine, text
|
|
|
|
url = args.database_url
|
|
if not url:
|
|
from app.core.settings import settings
|
|
|
|
url = settings.DATABASE_URL
|
|
|
|
engine = create_engine(url)
|
|
try:
|
|
with engine.connect() as conn:
|
|
rows = conn.execute(
|
|
text(
|
|
"""
|
|
SELECT t.id, t.name, p.code, s.status,
|
|
(SELECT count(*) FROM tenant_limit_overrides o
|
|
WHERE o.tenant_id = t.id) AS override_count
|
|
FROM tenants t
|
|
LEFT JOIN tenant_subscriptions s ON s.tenant_id = t.id
|
|
LEFT JOIN plans p ON p.id = s.plan_id
|
|
WHERE t.is_deleted = false
|
|
ORDER BY p.code NULLS FIRST, t.name
|
|
"""
|
|
)
|
|
).all()
|
|
|
|
catalogue = {
|
|
code: dict(
|
|
conn.execute(
|
|
text(
|
|
"SELECT l.key, l.value FROM plan_limits l "
|
|
" JOIN plans p ON p.id = l.plan_id WHERE p.code = :c"
|
|
),
|
|
{"c": code},
|
|
).all()
|
|
)
|
|
for (code,) in conn.execute(
|
|
text("SELECT code FROM plans WHERE is_public ORDER BY sort_order")
|
|
)
|
|
}
|
|
|
|
print(f"{len(rows)} live tenant(s)\n")
|
|
print(f"{'tenant':<34}{'plan':<14}{'status':<12}{'overrides':>10}")
|
|
print("-" * 72)
|
|
|
|
needs_review = []
|
|
for tenant_id, name, code, status, overrides in rows:
|
|
print(
|
|
f"{(name or str(tenant_id))[:33]:<34}"
|
|
f"{(code or 'NONE'):<14}{(status or '-'):<12}{overrides:>10}"
|
|
)
|
|
if code in (None, "custom"):
|
|
needs_review.append((tenant_id, name, overrides))
|
|
|
|
if not needs_review:
|
|
print("\nEvery tenant is on a named plan.")
|
|
return 0
|
|
|
|
print(
|
|
f"\n{len(needs_review)} tenant(s) are on `custom` or have no "
|
|
"subscription at all."
|
|
)
|
|
print(
|
|
"\nThat is what the D1 backfill does on purpose: it carries the\n"
|
|
"existing limits over as overrides and refuses to guess which\n"
|
|
"plan somebody bought. Their limits are unchanged and correct —\n"
|
|
"what is missing is the commercial fact.\n"
|
|
)
|
|
|
|
print("For reference, the public catalogue:\n")
|
|
keys = sorted({k for limits in catalogue.values() for k in limits})
|
|
print(f" {'plan':<16}" + "".join(f"{k:>20}" for k in keys))
|
|
for code, limits in catalogue.items():
|
|
print(
|
|
f" {code:<16}"
|
|
+ "".join(f"{human(k, limits.get(k)):>20}" for k in keys)
|
|
)
|
|
|
|
print(
|
|
"\nMove a tenant with PUT /api/billing/tenants/{id}/subscription,\n"
|
|
"then delete the carried-over overrides so the plan actually\n"
|
|
"applies — while an override exists, the plan is only a label."
|
|
)
|
|
return 1
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|