"""API v1 — suprafata gateway. Endpointuri: - POST /v1/prezentari: enqueue cu idempotenta (dedup pe idempotency_key UNIQUE). - GET /v1/prezentari, /v1/prezentari/{id}: monitorizare coada. - GET /v1/nomenclator: cache local. - GET /v1/mapari: listare mapari cont. """ from __future__ import annotations import csv import io import json from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from ...auth import require_api_access, resolve_account_id from ...crypto import encrypt_creds from ...db import get_connection from ...errors import eroare as err_eroare from ...idempotency import build_key, canonicalize_row from ...mapping import ( _emite_text_rule_hits, account_or_default, account_scope_clause, classify_prezentare, load_mapping_meta, load_nomenclator_codes, load_text_rules, pending_unmapped, reresolve_account, save_mapping, ) from ...models import ( PrezentareRequest, PrezentariResponse, SubmissionResult, ValidarePrezentariRequest, ValidareResponse, ValidareResult, ) from ...observ import log_event from ...payload_view import prezentare_din_payload from ...submissions_admin import ( SubmissionNotFound, SubmissionStateConflict, delete_submission, requeue_submission, ) router = APIRouter(prefix="/v1", tags=["v1"]) def _effective_on_unmapped_error(conn, acct: int, req_value: bool | None) -> bool: """Modul efectiv la cod necunoscut/nemapat (True => respinge cererea, False => needs_mapping). Precedenta: override per-cerere > default cont (on_unmapped_error_default) > False. """ if req_value is not None: return req_value row = conn.execute("SELECT on_unmapped_error_default FROM accounts WHERE id=?", (acct,)).fetchone() return bool(row["on_unmapped_error_default"]) if row else False def _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules=None) -> dict: """classify_prezentare + aplicarea modului on_unmapped_error. Cand exista coduri nemapate si error_mode=True, marcheaza outcome-ul ca respingere (blocked_error=True): rutele NU mai fac enqueue, ci intorc o eroare per-element. """ cl = classify_prezentare(content, mapping, mapping_meta, valid_codes, text_rules) cl["blocked_error"] = bool(cl["unmapped"]) and error_mode return cl def _erori_nemapate(unmapped: list[dict]) -> list[dict]: """Coduri nemapate imbogatite cu 3 niveluri (COD_NEMAPAT).""" return [ {**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} necunoscut/fara mapare RAR")} for u in unmapped ] def _motiv_clasificare(cl: dict) -> str | None: """Rezumat uman pe o linie pentru un rezultat de clasificare. None cand status='queued'. Acopera ramurile de blocaj: erori de continut (needs_data) si coduri fara mapare RAR (needs_mapping). Dupa US-001: needs_mapping apare EXCLUSIV cand unmapped e non-gol (ramura auto_send_oprit era inaccesibila si a fost eliminata). """ if cl["status"] == "queued": return None if cl["errors"]: return "; ".join( (e.get("problema") or e.get("message") or "") for e in cl["errors"] ).strip("; ") or "Date incomplete (respinse de RAR)." if cl["unmapped"]: coduri = ", ".join((u.get("cod_op_service") or "") for u in cl["unmapped"]) return f"Coduri fara mapare RAR: {coduri}" return None def _rezultat_enqueue(submission_id: int | None, cl: dict, **extra) -> SubmissionResult: """SubmissionResult onest dintr-un rezultat de clasificare. Populeaza erori (validare continut), nemapate (coduri fara mapare) si motiv (uman) pentru orice status != 'queued'. Aditiv: pe 'queued' toate raman goale/None. """ return SubmissionResult( submission_id=submission_id, status=cl["status"], erori=list(cl["errors"]), nemapate=_erori_nemapate(cl["unmapped"]), motiv=_motiv_clasificare(cl), **extra, ) def _rezultat_respins(submission_id: int | None, cl: dict) -> SubmissionResult: """Rezultat pentru on_unmapped_error=True: status='error', fara enqueue/reactivare. `erori` pastreaza COD_NEMAPAT (compat clienti vechi); `nemapate` + `motiv` adaugate. """ nem = _erori_nemapate(cl["unmapped"]) return SubmissionResult( submission_id=submission_id, status="error", erori=nem, nemapate=nem, motiv=_motiv_clasificare(cl), ) @router.post("/prezentari", response_model=PrezentariResponse) def create_prezentari( req: PrezentareRequest, account_id: int = Depends(require_api_access), ) -> PrezentariResponse: """Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission. Validarea de continut (app.validation) ruleaza inainte de enqueue: esecurile NU resping cererea, ci enqueue-aza cu status `needs_data` + motiv. JSON malformat -> 422 din Pydantic (validare de shape). account_id vine din cheia API (resolve_account_id): cont real cu cheie, implicit id=1 in dev fara cheie, 401 fara cheie valida in prod. Cand rar_credentials lipseste, submission-ul intra fara creds efemere: worker-ul cade pe creds-urile durabile ale contului (`accounts.rar_creds_enc`). """ acct = account_or_default(account_id) # Creds RAR efemere: criptate si lipite de fiecare submission nou pana la # primul login reusit pentru cont (worker le sterge atunci). Zero-storage at # rest — niciodata in clar in DB/loguri. Optional: cand lipsesc, # creds_enc=NULL si worker-ul foloseste creds-urile durabile ale contului. creds_enc = encrypt_creds(req.rar_credentials.model_dump()) if req.rar_credentials else None conn = get_connection() results: list[SubmissionResult] = [] try: # load_mapping_meta incarca maparea op->cod RAR; dupa US-001, auto_send nu mai tine randuri. mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} # Validare cod_prestatie fata de nomenclator + modul la cod necunoscut/nemapat. # valid_codes gol (nomenclator nepopulat) -> None (nu validam, ca sa nu blocam tot). valid_codes = load_nomenclator_codes(conn) or None # Reguli text incarcate o data per cerere (seam partajat cu dry-run). text_rules = load_text_rules(conn, acct) error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error) # T3 (PRD 5.17): enforce volum plan — INAINTE de build_key/enqueue (invariant idempotenta). # Decizie #21: respingere TOTALA a lotului (nu enqueue partial tacut). from ...config import get_settings as _get_settings from ...plans import PLANS, effective_tier, monthly_usage _settings = _get_settings() if _settings.enforce_plans: _acct_row = conn.execute( "SELECT tier, trial_until FROM accounts WHERE id=?", (acct,) ).fetchone() _now = datetime.now(timezone.utc) _et = effective_tier(_acct_row, _now) _plan_limit = PLANS[_et].get("monthly_limit") if _plan_limit is not None: _usage = monthly_usage(conn, acct, _now) _nr_cerut = len(req.prezentari) if _usage + _nr_cerut > _plan_limit: _remaining = max(0, _plan_limit - _usage) log_event( "plan_limita_lunara_atinsa", account_id=acct, nivel="WARNING", mesaj=f"Lot de {_nr_cerut} respins (usage={_usage}, limita={_plan_limit})", context={ "nr_cerut": _nr_cerut, "usage": _usage, "plan_limit": _plan_limit, "tier": _et, }, conn=conn, ) raise HTTPException( status_code=422, detail=err_eroare( "PLAN_LIMITA_LUNARA", cauza=( f"Ai trimis {_usage}/{_plan_limit} prezentari luna aceasta;" f" mai poti trimite {_remaining}." ), ), ) for prez in req.prezentari: content = prez.model_dump() # canonicalize_row inaintea build_key (odometru strip ".0", VIN upper). # build_key aplica account_or_default(account_id) inainte de hash: # None si 1 colapseaza la aceeasi cheie (canal API + canal import). canon = canonicalize_row(content) key = build_key(account_id, canon) # Aplica normalizarea si in content (odometru canonicalizat inainte de validare) content.update({ "vin": canon["vin"], "nr_inmatriculare": canon["nr_inmatriculare"], "odometru_final": canon["odometru_final"], }) existing = conn.execute( "SELECT id, status, id_prezentare FROM submissions WHERE idempotency_key=?", (key,), ).fetchone() if existing: # Un rand `error` (ex. creds RAR gresite) NU mai blocheaza tacit # retrimiterea aceluiasi continut. Il RE-ACTIVAM (re-clasificam + actualizam # creds + reset), printr-un UPDATE compare-and-swap pe status='error'. if existing["status"] == "error": cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules) if cl["blocked_error"]: # on_unmapped_error=True: nu reactivam; randul ramane 'error'. results.append(_rezultat_respins(existing["id"], cl)) continue cur = conn.execute( "UPDATE submissions SET status=?, payload_json=?, rar_error=?, " "rar_creds_enc=COALESCE(?, rar_creds_enc), retry_count=0, " "next_attempt_at=NULL, sending_since=NULL, purge_after=NULL, " "updated_at=datetime('now') WHERE id=? AND status='error'", (cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc, existing["id"]), ) if cur.rowcount == 1: # Creds noi se propaga si in canalul durabil (accounts.rar_creds_enc) # — ambele canale converg pe parola corectata. if req.rar_credentials is not None: conn.execute( "UPDATE accounts SET rar_creds_enc=? WHERE id=?", (encrypt_creds(req.rar_credentials.model_dump()), acct), ) _emite_text_rule_hits(conn, acct, existing["id"], cl["resolved"]) # Raspuns onest si la reactivare: daca re-clasificarea cade pe # needs_data/needs_mapping, expune motivul (nu doar status). results.append(_rezultat_enqueue(existing["id"], cl, reactivated=True)) continue # Cursa: alt POST/requeue a schimbat starea intre SELECT si UPDATE # (rowcount==0) -> raspuns dedup pe starea CURENTA. existing = conn.execute( "SELECT id, status, id_prezentare FROM submissions WHERE id=?", (existing["id"],), ).fetchone() results.append( SubmissionResult( submission_id=existing["id"], status=existing["status"], id_prezentare=existing["id_prezentare"], deduped=True, ) ) continue # Helper pur partajat cu dry-run: reproduce EXACT clasificarea # (canonicalize + mapare op->cod + validare; auto_send gate eliminat dupa US-001). cl = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules) if cl["blocked_error"]: # on_unmapped_error=True: respinge fara enqueue (cod necunoscut/nemapat). results.append(_rezultat_respins(None, cl)) continue cur = conn.execute( "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) " "VALUES (?, ?, ?, ?, ?, ?)", (key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc), ) sub_id = int(cur.lastrowid) _emite_text_rule_hits(conn, acct, sub_id, cl["resolved"]) # Raspuns onest: pe needs_data/needs_mapping expune erori/nemapate/motiv. results.append(_rezultat_enqueue(sub_id, cl)) # Audit cerere API per cont. Doar metadate (count + distributie status), # NICIUN camp de payload PII integral. Reuse conn (fara contentie WAL). dist: dict[str, int] = {} for r in results: if r.reactivated: cheie = "reactivated" elif r.deduped: cheie = "deduped" else: cheie = r.status dist[cheie] = dist.get(cheie, 0) + 1 log_event( "api_prezentari", account_id=acct, mesaj=f"{len(results)} prezentari procesate", context={"count": len(results), "distributie": dist}, conn=conn, ) finally: conn.close() return PrezentariResponse(results=results) @router.post("/prezentari/valideaza", response_model=ValidareResponse) def valideaza_prezentari( req: ValidarePrezentariRequest, account_id: int = Depends(resolve_account_id), ) -> ValidareResponse: """Dry-run: valideaza payload exact ca POST /prezentari, fara enqueue si fara efecte secundare. Intoarce pentru fiecare prezentare: verdictul (status_estimat), erorile de continut si codurile nemapate — exact ce ar obtine trimiterea reala pe acelasi payload + aceeasi mapare de cont. rar_credentials ignorat complet. """ acct = account_or_default(account_id) conn = get_connection() results: list[ValidareResult] = [] try: mapping_meta = load_mapping_meta(conn, acct) mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} valid_codes = load_nomenclator_codes(conn) or None # Acelasi seam ca trimiterea reala: dry-run trebuie sa vada aceleasi reguli text. text_rules = load_text_rules(conn, acct) error_mode = _effective_on_unmapped_error(conn, acct, req.on_unmapped_error) for i, prez in enumerate(req.prezentari): content = prez.model_dump() res = _classify_modal(content, mapping, mapping_meta, valid_codes, error_mode, text_rules) if res["blocked_error"]: res = {**res, "status": "error"} # Imbogatim fiecare element nemapat cu 3 niveluri COD_NEMAPAT nemapate = [ {**u, **err_eroare("COD_NEMAPAT", cauza=f"cod {u.get('cod_op_service')} fara mapare RAR")} for u in res["unmapped"] ] results.append(ValidareResult( index=i, valid=(res["status"] == "queued"), status_estimat=res["status"], erori=res["errors"], nemapate=nemapate, prestatii_rezolvate=res["resolved"], )) finally: conn.close() return ValidareResponse(results=results) @router.get("/prezentari") def list_prezentari( status: str | None = None, limit: int = 100, account_id: int = Depends(resolve_account_id), ) -> dict: conn = get_connection() try: scope_sql, scope_params = account_scope_clause(account_id) # payload_json e plaintext (vezi submissions.payload_json); il citim doar ca # sa derivam campurile afisabile prin helper-ul partajat, nu il expunem. cols = ( "id, status, id_prezentare, rar_status_code, retry_count, " "created_at, updated_at, payload_json" ) if status: rows = conn.execute( f"SELECT {cols} FROM submissions WHERE {scope_sql} AND status=? " f"ORDER BY id DESC LIMIT ?", scope_params + [status, limit], ).fetchall() else: rows = conn.execute( f"SELECT {cols} FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT ?", scope_params + [limit], ).fetchall() out = [] for r in rows: d = dict(r) # Campuri afisabile derivate din payload (acelasi helper ca dashboardul web); # payload_json brut nu se intoarce in raspuns. d["prezentare"] = prezentare_din_payload(d.pop("payload_json", None)) out.append(d) return {"submissions": out} finally: conn.close() # Campuri expuse de GET /v1/prezentari/{id} — allowlist explicita. # Exclude: rar_creds_enc, payload_json, idempotency_key, sending_since. _PREZENTARE_FIELDS = frozenset({ "id", "status", "id_prezentare", "rar_status_code", "retry_count", "next_attempt_at", "created_at", "updated_at", "account_id", "batch_id", "row_index", "purge_after", # rar_error e SIGUR de expus — contine doar coduri/mesaje de validare RAR si # erori din catalog (niciodata creds, ex. RAR_CREDS_INVALIDE poarta doar cauza # "credentiale RAR invalide", fara parola). Face recovery-ul observabil prin API. "rar_error", }) @router.get("/prezentari/{submission_id}") def get_prezentare( submission_id: int, account_id: int = Depends(resolve_account_id), ) -> dict: conn = get_connection() try: scope_sql, scope_params = account_scope_clause(account_id) row = conn.execute( f"SELECT * FROM submissions WHERE id=? AND {scope_sql}", [submission_id] + scope_params, ).fetchone() if not row: # Acelasi mesaj indiferent daca randul exista dar apartine altui cont # sau nu exista deloc — nu confirmam existenta. raise HTTPException(status_code=404, detail="submission inexistent") row_dict = dict(row) return {k: v for k, v in row_dict.items() if k in _PREZENTARE_FIELDS} finally: conn.close() @router.delete("/prezentari/{submission_id}") def delete_prezentare( submission_id: int, account_id: int = Depends(resolve_account_id), ) -> dict: """Sterge o trimitere blocata a contului cheii API. Raspuns 200 + body JSON (NU 204 — clienti VFP fac string-parse). Scope evaluat INAINTEA starii: cross-account / inexistent -> 404 (acelasi mesaj); own-account `sent`/`sending` -> 409 (conflict de stare). """ conn = get_connection() try: try: res = delete_submission(conn, account_id, submission_id) except SubmissionNotFound: raise HTTPException(status_code=404, detail="submission inexistent") except SubmissionStateConflict as exc: raise HTTPException( status_code=409, detail=f"trimiterea nu se poate sterge in starea '{exc.status}'", ) return {"ok": True, **res} finally: conn.close() @router.post("/prezentari/{submission_id}/repune") def repune_prezentare( submission_id: int, account_id: int = Depends(resolve_account_id), ) -> dict: """Re-pune in coada o trimitere blocata a contului cheii API. `error -> queued`, re-ruleaza classify. Acelasi oracol de scope/stare ca DELETE (404 cross-account/inexistent, 409 sent/sending). """ conn = get_connection() try: try: res = requeue_submission(conn, account_id, submission_id) except SubmissionNotFound: raise HTTPException(status_code=404, detail="submission inexistent") except SubmissionStateConflict as exc: raise HTTPException( status_code=409, detail=f"trimiterea nu se poate re-pune in starea '{exc.status}'", ) return {"ok": True, **res} finally: conn.close() @router.get("/nomenclator") def get_nomenclator() -> dict: conn = get_connection() try: rows = conn.execute( "SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie" ).fetchall() return {"nomenclator": [dict(r) for r in rows]} finally: conn.close() AUDIT_COLUMNS = [ "submission_id", "status", "id_prezentare", "account_id", "vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "prestatii", "rar_status_code", "created_at", "updated_at", "purge_after", ] def _audit_rows(conn, date_from: str | None, date_to: str | None, status: str, account_id: int): """Randuri audit filtrate pe cont + data(updated_at) in [from, to]. account_id = contul cheii API (scope obligatoriu — PII in CSV). Randuri cu account_id IS NULL apartin contului 1. b64_image NU intra in CSV. """ scope_sql, scope_params = account_scope_clause(account_id) sql = ( "SELECT id, status, id_prezentare, account_id, payload_json, rar_status_code, " "created_at, updated_at, purge_after FROM submissions" ) where = [scope_sql] params: list = list(scope_params) if status != "all": where.append("status=?") params.append(status) if date_from: where.append("date(updated_at) >= date(?)") params.append(date_from) if date_to: where.append("date(updated_at) <= date(?)") params.append(date_to) sql += " WHERE " + " AND ".join(where) sql += " ORDER BY id" for r in conn.execute(sql, params).fetchall(): try: p = json.loads(r["payload_json"]) if r["payload_json"] else {} except (ValueError, TypeError): p = {} codes = ",".join( (it.get("cod_prestatie") or it.get("cod_op_service") or "") for it in (p.get("prestatii") or []) if isinstance(it, dict) ) yield { "submission_id": r["id"], "status": r["status"], "id_prezentare": r["id_prezentare"] or "", # NULL→cont 1: coloana reflecta invariantul de scope, nu "" ambiguu. "account_id": account_or_default(r["account_id"]), "vin": p.get("vin") or "", "nr_inmatriculare": p.get("nr_inmatriculare") or "", "data_prestatie": p.get("data_prestatie") or "", "odometru_final": p.get("odometru_final") or "", "prestatii": codes, "rar_status_code": r["rar_status_code"] or "", "created_at": r["created_at"], "updated_at": r["updated_at"], "purge_after": r["purge_after"] or "", } @router.get("/audit/export") def audit_export( date_from: str | None = None, date_to: str | None = None, status: str = "sent", account_id: int = Depends(resolve_account_id), ) -> StreamingResponse: """CSV audit scoped pe contul cheii API. Filtre optionale `date_from`/`date_to` (YYYY-MM-DD) pe data(updated_at). `status` implicit `sent` (ce a ajuns efectiv la RAR); `status=all` exporta toata coada contului. Leaga retinerea 90 zile prin coloana `purge_after`. b64_image nu se exporta. """ conn = get_connection() try: buf = io.StringIO() writer = csv.DictWriter(buf, fieldnames=AUDIT_COLUMNS) writer.writeheader() for row in _audit_rows(conn, date_from, date_to, status, account_id): writer.writerow(row) data = buf.getvalue() finally: conn.close() fname = f"audit_{status}_{date_from or 'inceput'}_{date_to or 'azi'}.csv" return StreamingResponse( iter([data]), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="{fname}"'}, ) @router.get("/mapari") def get_mapari( key_account: int = Depends(resolve_account_id), account_id: int | None = None, ) -> dict: """Maparile operatie->cod ale contului curent. Parametrul `account_id` din query e pastrat pentru compatibilitate, dar contul efectiv vine MEREU din cheia API. Daca e prezent si difera -> 400. """ if account_id is not None and account_id != key_account: raise HTTPException( status_code=400, detail="account_id din query nu corespunde contului cheii API", ) conn = get_connection() try: rows = conn.execute( "SELECT * FROM operations_mapping WHERE account_id=? ORDER BY cod_op_service", (key_account,), ).fetchall() return {"mapari": [dict(r) for r in rows]} finally: conn.close() @router.get("/mapari/pending") def get_mapari_pending( account_id: int = Depends(resolve_account_id), ) -> dict: """Operatii ROAAUTO nemapate (din submission-uri needs_mapping) + sugestii fuzzy. Filtrate pe contul cheii API. Fiecare intrare: {account_id, cod_op_service, denumire, blocked, suggestions:[{cod_prestatie, nume_prestatie, score}]}. """ conn = get_connection() try: return {"pending": pending_unmapped(conn, account_id=account_id)} finally: conn.close() class MapareIn(BaseModel): cod_op_service: str = Field(..., min_length=1) cod_prestatie: str = Field(..., min_length=1) auto_send: bool = True @router.post("/mapari") def create_mapare( req: MapareIn, account_id: int = Depends(resolve_account_id), ) -> dict: """Salveaza/actualizeaza o mapare op->cod si re-rezolva submission-urile blocate. Contul vine din cheia API (NU din body) — un cont nu poate edita maparile altuia. Verifica intai ca `cod_prestatie` exista in nomenclator (nu lasam mapari catre coduri inexistente). Apoi upsert + re-rezolvare `needs_mapping`. """ conn = get_connection() try: cod = req.cod_prestatie.strip().upper() exists = conn.execute( "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod,) ).fetchone() if not exists: raise HTTPException(status_code=422, detail=f"cod_prestatie '{cod}' nu exista in nomenclator") save_mapping(conn, account_id, req.cod_op_service, cod, req.auto_send) stats = reresolve_account(conn, account_id) return {"saved": {"cod_op_service": req.cod_op_service.strip(), "cod_prestatie": cod}, "reresolve": stats} finally: conn.close() class RarCredsIn(BaseModel): """Creds RAR durabile per-cont. Stocate criptate (Fernet) in accounts.rar_creds_enc.""" email: str = Field(..., min_length=1) password: str = Field(..., min_length=1, repr=False) @router.post("/conturi/rar-creds") def set_rar_creds( req: RarCredsIn, account_id: int = Depends(resolve_account_id), ) -> dict: """Seteaza creds RAR durabile per-cont. Criptate Fernet in accounts.rar_creds_enc. Worker-ul le foloseste ca fallback cand submission-ul nu mai are creds (canal web fara re-pusher, restart worker). Contul vine din cheia API. """ acct = account_or_default(account_id) enc = encrypt_creds({"email": req.email, "password": req.password}) conn = get_connection() try: conn.execute( "UPDATE accounts SET rar_creds_enc=? WHERE id=?", (enc, acct), ) return {"ok": True, "account_id": acct} finally: conn.close() @router.delete("/conturi/rar-creds") def delete_rar_creds( account_id: int = Depends(resolve_account_id), ) -> dict: """Sterge creds RAR durabile per-cont (revenire la modelul efemer Treapta 1).""" acct = account_or_default(account_id) conn = get_connection() try: conn.execute("UPDATE accounts SET rar_creds_enc=NULL WHERE id=?", (acct,)) return {"ok": True, "account_id": acct} finally: conn.close()