accounts: cli — seed depuis un fichier JSON ou HFMD_SEED_USERS, plus aucune adresse tierce en dur
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 changed file +40 −12
modified
hfmarketdata/api/accounts/cli.py
+40 −12
@@ -6,18 +6,25 @@ | ||
| 6 | 6 | hfmd users disable <email> | enable <email> |
| 7 | 7 | hfmd users invite-resend <email> [--no-mail] |
| 8 | 8 | hfmd keys list <email> |
| 9 | − hfmd seed [--show-key] [--no-mail] # idempotent: 3 users + admin | |
| 9 | + hfmd keys rotate <email> [--show-key] | |
| 10 | + hfmd seed [seed.json] [--show-key] [--no-mail] # idempotent; users from the JSON file or $HFMD_SEED_USERS | |
| 10 | 11 | |
| 11 | 12 | Full API keys are printed ONLY at creation and ONLY with `--show-key`. Invitation links are printed when |
| 12 | 13 | the e-mail could not be delivered (no HFMD_RESEND_API_KEY) so you can forward them. |
| 13 | 14 | |
| 15 | +Seed file format (never commit real addresses): `[{"name": "…", "email": "…", "tier": "free|high_usage|unlimited", | |
| 16 | +"role": "user|admin"}, …]`. The same JSON may be passed inline through the `HFMD_SEED_USERS` environment variable. | |
| 17 | + | |
| 14 | 18 | Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 15 | 19 | """ |
| 16 | 20 | from __future__ import annotations |
| 17 | 21 | |
| 18 | 22 | import argparse |
| 23 | +import json | |
| 24 | +import os | |
| 19 | 25 | import sys |
| 20 | 26 | from collections.abc import Iterable |
| 27 | +from pathlib import Path | |
| 21 | 28 | |
| 22 | 29 | from core.config import settings |
| 23 | 30 | from core.db import session |
@@ -25,13 +32,31 @@ from core.errors import ApiError | ||
| 25 | 32 | |
| 26 | 33 | from . import models, service |
| 27 | 34 | |
| 28 | −SEED_USERS = [ | |
| 29 | − {"name": "Luca Iannelli", "email": "ianez84@gmail.com", "tier": "free", "role": "user"}, | |
| 30 | − {"name": "Tim Erdmann", "email": "timerdmann.uni@gmail.com", "tier": "free", "role": "user"}, | |
| 31 | − {"name": "Javier Slaton", "email": "javier.slaton@gmail.com", "tier": "free", "role": "user"}, | |
| 32 | − {"name": "Simon-Pierre Boucher", "email": "contact@spboucher.ai", "tier": "high_usage", "role": "admin"}, | |
| 33 | −] | |
| 34 | 35 | ACTOR = "cli" |
| 36 | +SEED_ENV = "HFMD_SEED_USERS" | |
| 37 | + | |
| 38 | + | |
| 39 | +def load_seed(path: str | None) -> list[dict]: | |
| 40 | + """Seed users from a JSON file (argument) or the HFMD_SEED_USERS environment variable (inline JSON).""" | |
| 41 | + if path: | |
| 42 | + raw = Path(path).read_text(encoding="utf-8") | |
| 43 | + else: | |
| 44 | + raw = os.environ.get(SEED_ENV, "") | |
| 45 | + if not raw.strip(): | |
| 46 | + raise ApiError(400, "INVALID_PARAMETER", f"no seed: pass a JSON file or set {SEED_ENV}") | |
| 47 | + try: | |
| 48 | + users = json.loads(raw) | |
| 49 | + except ValueError as exc: | |
| 50 | + raise ApiError(400, "INVALID_PARAMETER", f"seed is not valid JSON: {exc}") from exc | |
| 51 | + if not isinstance(users, list) or not users: | |
| 52 | + raise ApiError(400, "INVALID_PARAMETER", "seed must be a non-empty JSON list of users") | |
| 53 | + out = [] | |
| 54 | + for i, u in enumerate(users): | |
| 55 | + if not isinstance(u, dict) or not u.get("email"): | |
| 56 | + raise ApiError(400, "INVALID_PARAMETER", f"seed entry {i} needs at least an email") | |
| 57 | + out.append({"name": str(u.get("name") or ""), "email": str(u["email"]), "tier": str(u.get("tier") or "free"), | |
| 58 | + "role": str(u.get("role") or "user")}) | |
| 59 | + return out | |
| 35 | 60 | |
| 36 | 61 | |
| 37 | 62 | def table(rows: Iterable[dict], columns: list[str], out=None) -> None: |
@@ -70,7 +95,7 @@ def provision(s, name: str, email: str, *, tier: str, admin: bool, send_mail: bo | ||
| 70 | 95 | if u is None: |
| 71 | 96 | u, created_key, issued = service.invite(s, email, name, tier=tier, role="admin" if admin else "user", |
| 72 | 97 | actor=ACTOR, send_mail=send_mail) |
| 73 | − link = None if issued.delivery.delivered else issued.link | |
| 98 | + link = None if (issued.delivery.delivered or issued.delivery.queued) else issued.link | |
| 74 | 99 | else: |
| 75 | 100 | # existing user: never touch the tier (use `users set-tier`), only promote to admin when asked |
| 76 | 101 | if admin and u.role != "admin": |
@@ -80,7 +105,7 @@ def provision(s, name: str, email: str, *, tier: str, admin: bool, send_mail: bo | ||
| 80 | 105 | created_key, _ = service.create_key(s, u, "default", actor=ACTOR, notify=False) |
| 81 | 106 | if u.status == "invited" and not service.pending_link(s, u, "invite"): |
| 82 | 107 | issued = service.issue_token(s, u, "invite", send_mail=send_mail, actor=ACTOR) |
| 83 | − link = None if issued.delivery.delivered else issued.link | |
| 108 | + link = None if (issued.delivery.delivered or issued.delivery.queued) else issued.link | |
| 84 | 109 | return u, created_key, link |
| 85 | 110 | |
| 86 | 111 | |
@@ -133,7 +158,8 @@ def cmd_users_invite_resend(a) -> int: | ||
| 133 | 158 | if not service.active_keys(s, u): |
| 134 | 159 | service.create_key(s, u, "default", actor=ACTOR, notify=False) |
| 135 | 160 | issued = service.issue_token(s, u, "invite", send_mail=not a.no_mail, actor=ACTOR) |
| 136 | − print(f"{u.email}: invitation " + ("sent by e-mail" if issued.delivery.delivered else f"link → {issued.link}")) | |
| 161 | + print(f"{u.email}: invitation " + ("queued for e-mail delivery" if issued.delivery.queued or issued.delivery.delivered | |
| 162 | + else f"link → {issued.link}")) | |
| 137 | 163 | return 0 |
| 138 | 164 | |
| 139 | 165 | |
@@ -163,8 +189,9 @@ def cmd_keys_list(a) -> int: | ||
| 163 | 189 | |
| 164 | 190 | def cmd_seed(a) -> int: |
| 165 | 191 | rows = [] |
| 192 | + seed_users = load_seed(a.file) | |
| 166 | 193 | with session() as s: |
| 167 | − for spec in SEED_USERS: | |
| 194 | + for spec in seed_users: | |
| 168 | 195 | u, raw, link = provision(s, spec["name"], spec["email"], tier=spec["tier"], admin=spec["role"] == "admin", |
| 169 | 196 | send_mail=not a.no_mail) |
| 170 | 197 | rows.append(_row(s, u, raw, link, a.show_key)) |
@@ -200,7 +227,8 @@ def build_parser() -> argparse.ArgumentParser: | ||
| 200 | 227 | kl = keys.add_parser("list"); kl.add_argument("email"); kl.set_defaults(fn=cmd_keys_list) |
| 201 | 228 | kr = keys.add_parser("rotate", help="revoke + recreate the user's key"); kr.add_argument("email"); kr.add_argument("--show-key", action="store_true"); kr.set_defaults(fn=cmd_keys_rotate) |
| 202 | 229 | |
| 203 | − seed = sub.add_parser("seed", help="create the initial users (idempotent)") | |
| 230 | + seed = sub.add_parser("seed", help=f"create the initial users from a JSON file or ${SEED_ENV} (idempotent)") | |
| 231 | + seed.add_argument("file", nargs="?", help="JSON list of {name, email, tier, role}") | |
| 204 | 232 | seed.add_argument("--show-key", action="store_true") |
| 205 | 233 | seed.add_argument("--no-mail", action="store_true") |
| 206 | 234 | seed.set_defaults(fn=cmd_seed) |
| 207 | 235 | |