"""Bootstrap the PM→ERP service account (one-off, idempotent). 1. Creates Casdoor user `pm-service` (org `inventory`) via the Casdoor admin API. 2. Performs a password grant so ERP auto-provisions the users row. 3. Promotes that row to role `admin` (read-only across all projects — the ERP inventory router lets admin read without a project scope). Usage (from the pm-api container): PM_ERP_SERVICE_PASSWORD=... python -m tools.seed_erp_service """ import asyncio import base64 import json import logging import os import urllib.request from app.config import settings logging.basicConfig(level=logging.INFO) log = logging.getLogger("pm.seed_erp") ORG = settings.ERP_CASDOOR_ORGANIZATION USERNAME = settings.ERP_SERVICE_USERNAME PASSWORD = settings.ERP_SERVICE_PASSWORD or os.environ.get("PM_ERP_SERVICE_PASSWORD", "pm-service-2026") EMAIL = f"{USERNAME}@sample.ir" ERP_DSN = "postgresql://postgres:postgres@procurement-postgres:5432/procurement" def _post(url: str, body: dict, headers: dict | None = None) -> dict: req = urllib.request.Request( url, data=json.dumps(body).encode(), headers={"Content-Type": "application/json", **(headers or {})}, method="POST", ) resp = urllib.request.urlopen(req, timeout=15) return json.loads(resp.read()) def casdoor_admin_cookie() -> str: resp = urllib.request.urlopen(urllib.request.Request( f"{settings.ERP_CASDOOR_ENDPOINT}/api/login", data=json.dumps({ "type": "login", "organization": "built-in", "username": "admin", "password": "123", "application": "app-built-in", }).encode(), headers={"Content-Type": "application/json"}, method="POST", ), timeout=15) data = json.loads(resp.read()) if data.get("status") != "ok": raise SystemExit(f"casdoor admin login failed: {data}") return (resp.headers.get("Set-Cookie") or "").split(";")[0] async def main() -> None: # 1) casdoor user (idempotent — casdoor rejects duplicates, that's fine) cookie = casdoor_admin_cookie() for path, body in [ ("/api/add-user", { "owner": ORG, "name": USERNAME, "displayName": "PM Service", "firstName": "PM", "lastName": "Service", "email": EMAIL, "password": PASSWORD, "passwordType": "plain", "type": "normal-user", }), (f"/api/update-user?id={ORG}/{USERNAME}&columns=password_type", {"passwordType": "plain"}), ]: try: result = _post(f"{settings.ERP_CASDOOR_ENDPOINT}{path}", body, {"Cookie": cookie}) log.info("%s → %s", path.split("?")[0], result.get("msg", result.get("status"))) except Exception as exc: # noqa: BLE001 log.warning("%s failed: %s", path.split("?")[0], exc) # 2) password grant (PM never verifies this token — it just forwards it) grant = _post(f"{settings.ERP_CASDOOR_ENDPOINT}/api/login/oauth/access_token", { "grant_type": "password", "client_id": settings.ERP_CASDOOR_CLIENT_ID, "client_secret": settings.ERP_CASDOOR_CLIENT_SECRET, "username": USERNAME, "password": PASSWORD, }) token = grant.get("access_token") if not token: raise SystemExit(f"password grant failed: {grant}") claims = json.loads(base64.urlsafe_b64decode(token.split(".")[1] + "=" * (-len(token.split(".")[1]) % 4))) casdoor_id = str(claims.get("sub") or claims.get("id") or "") log.info("grant ok, casdoor_id=%s", casdoor_id[:8]) # trigger ERP auto-provisioning me_req = urllib.request.Request( f"{settings.ERP_BASE_URL}/api/v1/users/me", headers={"Authorization": f"Bearer {token}"}, ) try: urllib.request.urlopen(me_req, timeout=15).read() log.info("ERP provisioning call ok") except Exception as exc: # noqa: BLE001 log.warning("provisioning call failed (row may already exist): %s", exc) # 3) promote in ERP DB (direct SQL — this service account is PM infrastructure) import asyncpg conn = await asyncpg.connect(ERP_DSN) try: updated = await conn.execute( "UPDATE users SET role='admin' WHERE casdoor_id=$1", casdoor_id ) log.info("ERP role promotion: %s", updated) finally: await conn.close() log.info("service account ready: %s / ***", USERNAME) if __name__ == "__main__": asyncio.run(main())