Primer commit
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mueve custom fields entre folders (bloques) en una o varias cuentas GHL.
|
||||
|
||||
Cada custom field en GHL pertenece a un "folder" (bloque visual en la UI de
|
||||
configuración). Aunque los fields heredan opciones/dataType desde Marca, la
|
||||
asociación a un folder se hace por `parentId`, y los folder IDs son distintos
|
||||
en cada location (aunque los nombres suelen coincidir). Este script:
|
||||
|
||||
1. Resuelve los folder IDs por nombre normalizado en cada cuenta.
|
||||
2. Localiza el field a mover por nombre normalizado.
|
||||
3. PUT del field con el nuevo `parentId`.
|
||||
|
||||
Verificado contra la API V2 de GHL:
|
||||
- `PUT /locations/{loc}/customFields/{id}` con body `{"parentId": "..."}` →
|
||||
status 200, mueve el field.
|
||||
- El cambio es puramente organizacional (UI). No afecta valores guardados,
|
||||
workflows, formularios públicos ni integraciones.
|
||||
|
||||
Uso típico:
|
||||
|
||||
# Plan en una cuenta:
|
||||
python scripts/move_custom_fields_between_folders.py \\
|
||||
--location Vf7qQl3L9vakJ8hDtQ8e
|
||||
|
||||
# Apply en las 2 DEMOs:
|
||||
python scripts/move_custom_fields_between_folders.py \\
|
||||
--location Vf7qQl3L9vakJ8hDtQ8e --apply --run-id <uuid>
|
||||
|
||||
# Rollout final:
|
||||
python scripts/move_custom_fields_between_folders.py \\
|
||||
--all --apply --run-id <uuid>
|
||||
|
||||
Para cambiar los movimientos, editar la tabla `MOVES` al inicio del script.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
import warnings
|
||||
|
||||
warnings.filterwarnings("ignore", message=r"urllib3 .* doesn't match a supported version!")
|
||||
import requests
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if ROOT_DIR not in sys.path:
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
|
||||
import sync_engine # noqa: E402
|
||||
import script_audit # noqa: E402
|
||||
from paths import MIGRATIONS_DIR # noqa: E402
|
||||
|
||||
BASE_URL = "https://services.leadconnectorhq.com"
|
||||
API_VERSION = "2021-07-28"
|
||||
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Tabla de movimientos
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Cada entrada describe un field a mover. El match se hace por nombre
|
||||
# normalizado (case + acentos insensitive) tanto del field como del folder.
|
||||
|
||||
MOVES = [
|
||||
{
|
||||
"object": "contact",
|
||||
"field_name": "Sucursal", # 48/50 cuentas lo tienen en "Form"
|
||||
"target_folder": "Contact",
|
||||
},
|
||||
{
|
||||
"object": "contact",
|
||||
"field_name": "Marca del Vehículo", # match por norm (acepta variantes)
|
||||
"target_folder": "Contact",
|
||||
},
|
||||
{
|
||||
"object": "contact",
|
||||
"field_name": "Año del Vehículo",
|
||||
"target_folder": "Contact",
|
||||
},
|
||||
{
|
||||
"object": "contact",
|
||||
"field_name": "Versión del Vehículo",
|
||||
"target_folder": "Contact",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
_last_request_by_token: dict = {}
|
||||
|
||||
|
||||
def normalize_name(value):
|
||||
s = str(value or "").strip().lower()
|
||||
s = unicodedata.normalize("NFKD", s)
|
||||
s = "".join(c for c in s if not unicodedata.combining(c))
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
def wait_for_rate_limit(token):
|
||||
elapsed = time.time() - _last_request_by_token.get(token, 0)
|
||||
if elapsed < 0.110:
|
||||
time.sleep(0.110 - elapsed)
|
||||
_last_request_by_token[token] = time.time()
|
||||
|
||||
|
||||
def ghl_request(method, endpoint, token, *, params=None, json_body=None,
|
||||
version=API_VERSION, retries=3):
|
||||
wait_for_rate_limit(token)
|
||||
url = endpoint if endpoint.startswith("http") else f"{BASE_URL}{endpoint}"
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Version": version,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
last_exc = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
resp = requests.request(method, url, headers=headers, params=params,
|
||||
json=json_body, timeout=30)
|
||||
except requests.RequestException as exc:
|
||||
last_exc = exc
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
continue
|
||||
if resp.status_code == 429:
|
||||
time.sleep(1.0 * (attempt + 1))
|
||||
continue
|
||||
if resp.status_code >= 500:
|
||||
time.sleep(1.0 * (attempt + 1))
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
if resp.status_code == 204:
|
||||
return {}
|
||||
return resp.json()
|
||||
if last_exc:
|
||||
raise last_exc
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Lectura del schema (campos + folders)
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def list_custom_fields_and_folders(account, model):
|
||||
"""Lista los custom fields. Folders se obtienen por GET individual por
|
||||
`parentId` (el endpoint legacy no devuelve folders en la lista)."""
|
||||
data = ghl_request("GET", f"/locations/{account['location_id']}/customFields",
|
||||
account["token"], params={"model": model})
|
||||
items = data.get("customFields") or []
|
||||
fields = [f for f in items if f.get("documentType") != "folder"]
|
||||
# Resolver folders únicos referenciados por parentId
|
||||
parent_ids = {f.get("parentId") for f in fields if f.get("parentId")}
|
||||
folders = []
|
||||
for pid in parent_ids:
|
||||
try:
|
||||
d = ghl_request("GET", f"/locations/{account['location_id']}/customFields/{pid}",
|
||||
account["token"])
|
||||
cf = d.get("customField") or d
|
||||
if cf.get("documentType") == "folder":
|
||||
folders.append(cf)
|
||||
except Exception as exc:
|
||||
print(f" ⚠ No pude leer folder {pid}: {exc}")
|
||||
return fields, folders
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Núcleo: plan + apply por (cuenta, move)
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def plan_account(account):
|
||||
"""Devuelve lista de acciones a ejecutar para esta cuenta."""
|
||||
actions = []
|
||||
# Cache por object para no llamar API repetido
|
||||
cache = {}
|
||||
for move in MOVES:
|
||||
obj = move["object"]
|
||||
if obj not in cache:
|
||||
cache[obj] = list_custom_fields_and_folders(account, obj)
|
||||
fields, folders = cache[obj]
|
||||
|
||||
field_norm = normalize_name(move["field_name"])
|
||||
folder_norm = normalize_name(move["target_folder"])
|
||||
|
||||
field = next((f for f in fields if normalize_name(f.get("name")) == field_norm), None)
|
||||
folder = next((fo for fo in folders if normalize_name(fo.get("name")) == folder_norm), None)
|
||||
|
||||
if not field:
|
||||
actions.append({
|
||||
"status": "field_not_found", "move": move,
|
||||
"details": f"No existe field {move['field_name']!r} en {obj}.",
|
||||
})
|
||||
continue
|
||||
if not folder:
|
||||
actions.append({
|
||||
"status": "folder_not_found", "move": move,
|
||||
"field_id": field.get("id"), "field_name": field.get("name"),
|
||||
"details": f"No existe folder {move['target_folder']!r} en esta cuenta.",
|
||||
})
|
||||
continue
|
||||
if field.get("parentId") == folder.get("id"):
|
||||
actions.append({
|
||||
"status": "already_in_target", "move": move,
|
||||
"field_id": field.get("id"), "field_name": field.get("name"),
|
||||
"current_parent_id": field.get("parentId"),
|
||||
"target_folder_id": folder.get("id"),
|
||||
"target_folder_name": folder.get("name"),
|
||||
})
|
||||
continue
|
||||
actions.append({
|
||||
"status": "to_move", "move": move,
|
||||
"field_id": field.get("id"), "field_name": field.get("name"),
|
||||
"current_parent_id": field.get("parentId"),
|
||||
"target_folder_id": folder.get("id"),
|
||||
"target_folder_name": folder.get("name"),
|
||||
})
|
||||
return actions
|
||||
|
||||
|
||||
def apply_actions(account, actions, *, dry_run, run_id):
|
||||
"""Ejecuta los `to_move` del plan. Devuelve stats."""
|
||||
stats = {"moved": 0, "skipped": 0, "errors": []}
|
||||
# Snapshot
|
||||
snap = {
|
||||
"account": account["nombre"],
|
||||
"location_id": account["location_id"],
|
||||
"timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"dry_run": dry_run,
|
||||
"actions": actions,
|
||||
}
|
||||
os.makedirs(MIGRATIONS_DIR, exist_ok=True)
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe = "".join(c if c.isalnum() else "_" for c in account["nombre"])[:40]
|
||||
snap_path = os.path.join(MIGRATIONS_DIR, f"move_folders_{safe}_{ts}.json")
|
||||
with open(snap_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(snap, fh, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
for a in actions:
|
||||
if a["status"] != "to_move":
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
if dry_run:
|
||||
continue
|
||||
change_id = None
|
||||
if run_id:
|
||||
change_id = script_audit.record_change(
|
||||
run_id, account["location_id"], "__schema__",
|
||||
a["field_id"], a["field_id"], a["field_name"],
|
||||
{"parentId": a["current_parent_id"]},
|
||||
{"parentId": a["target_folder_id"]},
|
||||
)
|
||||
try:
|
||||
ghl_request("PUT", f"/locations/{account['location_id']}/customFields/{a['field_id']}",
|
||||
account["token"], json_body={"parentId": a["target_folder_id"]})
|
||||
if change_id:
|
||||
script_audit.mark_change(change_id, "applied")
|
||||
stats["moved"] += 1
|
||||
except Exception as exc:
|
||||
if change_id:
|
||||
script_audit.mark_change(change_id, "failed", str(exc))
|
||||
stats["errors"].append({"field_id": a["field_id"], "error": str(exc)})
|
||||
|
||||
return stats, snap_path
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Reporte
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def print_plan(account, actions):
|
||||
print(f"\n[Plan] {account['nombre']} ({account['location_id']})")
|
||||
for a in actions:
|
||||
m = a["move"]
|
||||
if a["status"] == "to_move":
|
||||
print(f" ▸ MOVER {m['object']}.{a['field_name']!r}")
|
||||
print(f" from parentId={a['current_parent_id']} → to {a['target_folder_name']!r} ({a['target_folder_id']})")
|
||||
elif a["status"] == "already_in_target":
|
||||
print(f" ✓ ya está {m['object']}.{a['field_name']!r} en {a['target_folder_name']!r}")
|
||||
else:
|
||||
print(f" ✗ {a['status']} {m['field_name']!r}: {a.get('details','')}")
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# CLI
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DEMO_LOCATION_IDS = ["Vf7qQl3L9vakJ8hDtQ8e", "Z64WQKORPVwXb5mn68Ef"]
|
||||
|
||||
|
||||
def select_targets(args, accounts):
|
||||
if args.location:
|
||||
m = [a for a in accounts if a["location_id"] == args.location]
|
||||
if not m:
|
||||
raise SystemExit(f"Location {args.location} no encontrada.")
|
||||
return m
|
||||
if args.demos:
|
||||
return [a for a in accounts if a["location_id"] in DEMO_LOCATION_IDS]
|
||||
if args.brand:
|
||||
return [a for a in accounts if a["location_id"] == BRAND_LOCATION_ID]
|
||||
if args.all_productive:
|
||||
return [a for a in accounts
|
||||
if a["location_id"] not in DEMO_LOCATION_IDS
|
||||
and a["location_id"] != BRAND_LOCATION_ID]
|
||||
if args.all:
|
||||
return accounts
|
||||
raise SystemExit("Especifica --location, --demos, --brand, --all-productive o --all.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Mueve custom fields entre folders en GHL.")
|
||||
parser.add_argument("--location", help="Procesar solo esta cuenta.")
|
||||
parser.add_argument("--demos", action="store_true",
|
||||
help="Procesar solo las 2 cuentas DEMO.")
|
||||
parser.add_argument("--brand", action="store_true",
|
||||
help="Procesar solo Marca.")
|
||||
parser.add_argument("--all-productive", action="store_true",
|
||||
help="Procesar todas las sucursales productivas (excluye Marca y DEMOs).")
|
||||
parser.add_argument("--all", action="store_true", help="Procesar todas las cuentas.")
|
||||
parser.add_argument("--apply", action="store_true",
|
||||
help="Ejecuta los cambios. Sin este flag corre en --dry-run.")
|
||||
parser.add_argument("--run-id", help="ID para registrar en script_audit.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
if args.run_id:
|
||||
script_audit.init_audit_db()
|
||||
|
||||
dry_run = not args.apply
|
||||
accounts = sync_engine.parse_accounts_csv()
|
||||
targets = select_targets(args, accounts)
|
||||
|
||||
print(f"Modo: {'DRY-RUN' if dry_run else 'APPLY'}")
|
||||
print(f"Movimientos configurados: {len(MOVES)}")
|
||||
for m in MOVES:
|
||||
print(f" - {m['object']}.{m['field_name']!r} → folder {m['target_folder']!r}")
|
||||
print(f"Cuentas en scope: {len(targets)}\n")
|
||||
|
||||
total_moved = total_skipped = 0
|
||||
errors_global = []
|
||||
for acc in targets:
|
||||
if not script_audit.wait_if_paused_or_stopped(args.run_id):
|
||||
print("Detención solicitada. Saliendo.")
|
||||
break
|
||||
try:
|
||||
actions = plan_account(acc)
|
||||
except Exception as exc:
|
||||
print(f" [{acc['nombre']}] ERROR plan: {exc}")
|
||||
errors_global.append({"account": acc["nombre"], "error": str(exc)})
|
||||
continue
|
||||
print_plan(acc, actions)
|
||||
try:
|
||||
stats, snap_path = apply_actions(acc, actions, dry_run=dry_run, run_id=args.run_id)
|
||||
total_moved += stats["moved"]
|
||||
total_skipped += stats["skipped"]
|
||||
errors_global.extend(stats["errors"])
|
||||
print(f" snapshot: {snap_path}")
|
||||
if not dry_run:
|
||||
print(f" ✓ moved={stats['moved']} skipped={stats['skipped']} errors={len(stats['errors'])}")
|
||||
except Exception as exc:
|
||||
print(f" ✗ apply falló: {exc}")
|
||||
errors_global.append({"account": acc["nombre"], "error": str(exc)})
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
if dry_run:
|
||||
print("DRY-RUN — no se modificó nada. Revisa el plan y vuelve a correr con --apply.")
|
||||
print(f"Cuentas procesadas : {len(targets)}")
|
||||
print(f"Movimientos hechos : {total_moved}")
|
||||
print(f"Acciones saltadas : {total_skipped}")
|
||||
print(f"Errores : {len(errors_global)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user