#!/usr/bin/env python3 # -*- coding: utf-8 -*- """enable_duplicate_opportunity.py Activa el ajuste `settings.allowDuplicateOpportunity = true` en una location de GHL (por defecto la cuenta de Marca, GbKkBpCmKu2QmloKFHy3) para que un contacto pueda tener multiples oportunidades en el mismo pipeline (necesario para replicar multi-empeno Sucursal -> Marca). POR QUE EXISTE: La replicacion Sucursal->Marca de multi-empenos fallaba con `400 "Can not create duplicate opportunity for the contact"` porque Marca tenia el flag en false (las sucursales ya lo tienen en true). Ver memoria opp_multiplicity_replication_gap. TOKEN: Escribir en /locations/{id} requiere un token de AGENCIA con scope locations.write (el token PIT por-location del CSV da 401 en este PUT). Se lee de la variable de entorno GHL_AGENCY_TOKEN (definida en .env, gitignored). NUNCA se imprime. SEGURIDAD: - El PUT envia SOLO {"settings": {"allowDuplicateOpportunity": }}; GHL hace merge y preserva el resto de settings (crmSettings, saasSettings, etc.). - Snapshot del settings actual en generated/migrations/ antes de mutar. - Registro en script_audit (object_type 'location'). OJO: el rollback automatico del dashboard (rollback_run) NO revierte settings de location -- el rollback es manual con --disable (PUT allowDuplicateOpportunity=false) desde el snapshot. Uso: python scripts/enable_duplicate_opportunity.py # DRY-RUN (no escribe) python scripts/enable_duplicate_opportunity.py --apply --run-id python scripts/enable_duplicate_opportunity.py --location [--apply] python scripts/enable_duplicate_opportunity.py --disable --apply --run-id # rollback python scripts/enable_duplicate_opportunity.py --json """ import argparse import json import os import sys import uuid from datetime import datetime 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) try: from dotenv import load_dotenv load_dotenv(os.path.join(ROOT_DIR, ".env")) except Exception: pass import requests # noqa: E402 import script_audit # noqa: E402 from paths import MIGRATIONS_DIR # noqa: E402 BASE_URL = "https://services.leadconnectorhq.com" BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3" SETTING_KEY = "allowDuplicateOpportunity" def _headers(token, *, write=False): h = { "Authorization": f"Bearer {token}", "Version": "2021-07-28", "Accept": "application/json", "User-Agent": "Mozilla/5.0", # GHL rechaza sin User-Agent (403) } if write: h["Content-Type"] = "application/json" return h def get_location(token, location_id): r = requests.get(f"{BASE_URL}/locations/{location_id}", headers=_headers(token), timeout=15) if r.status_code != 200: raise RuntimeError(f"GET /locations/{location_id} -> HTTP {r.status_code}: {r.text[:200]}") return r.json().get("location", {}) def put_setting(token, location_id, value): body = {"settings": {SETTING_KEY: value}} r = requests.put( f"{BASE_URL}/locations/{location_id}", headers=_headers(token, write=True), json=body, timeout=15, ) if r.status_code != 200: raise RuntimeError(f"PUT /locations/{location_id} -> HTTP {r.status_code}: {r.text[:300]}") return r.json() def snapshot_settings(location_id, name, settings): os.makedirs(str(MIGRATIONS_DIR), exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") path = os.path.join(str(MIGRATIONS_DIR), f"enable_dup_opp_{location_id}_{ts}.json") with open(path, "w", encoding="utf-8") as f: json.dump({"location_id": location_id, "name": name, "settings_before": settings}, f, ensure_ascii=False, indent=2) return path def run(location_id=BRAND_LOCATION_ID, target_value=True, dry_run=True, run_id=None, log=print): token = os.getenv("GHL_AGENCY_TOKEN") if not token: raise RuntimeError("Falta GHL_AGENCY_TOKEN en el entorno (.env). Es el token de agencia con locations.write.") log(f"[{datetime.now().strftime('%H:%M:%S')}] === enable_duplicate_opportunity (target={target_value}) ===") log(f"Modo: {'APPLY' if not dry_run else 'DRY-RUN (no escribe)'} | location={location_id}") loc = get_location(token, location_id) name = loc.get("name", "?") settings = loc.get("settings", {}) current = settings.get(SETTING_KEY, None) log(f"Location: {name} | {SETTING_KEY} actual = {current}") snap = snapshot_settings(location_id, name, settings) log(f"Snapshot settings -> {snap}") result = { "location_id": location_id, "name": name, "before": current, "target": target_value, "dry_run": dry_run, "changed": False, "snapshot": snap, } if current is target_value: log(f"No-op: {SETTING_KEY} ya es {target_value}.") return result if dry_run: log(f"DRY-RUN: cambiaria {SETTING_KEY} {current} -> {target_value} (PUT no enviado).") return result change_id = None if run_id: change_id = script_audit.record_change( run_id, location_id, "location", location_id, SETTING_KEY, SETTING_KEY, {"value": current}, {"value": target_value}, ) try: put_setting(token, location_id, target_value) if change_id: script_audit.mark_change(change_id, "applied") except Exception as exc: if change_id: script_audit.mark_change(change_id, "failed", str(exc)) raise # Verificacion after = get_location(token, location_id).get("settings", {}).get(SETTING_KEY) result["after"] = after result["changed"] = (after is target_value) log(f"Verificacion: {SETTING_KEY} = {after} -> {'OK' if result['changed'] else 'FALLO'}") return result def main(): p = argparse.ArgumentParser(description="Activa allowDuplicateOpportunity en una location de GHL.") p.add_argument("--location", default=BRAND_LOCATION_ID, help="location_id (default: Marca).") p.add_argument("--apply", action="store_true", help="Aplica el cambio (sin esto, dry-run).") p.add_argument("--disable", action="store_true", help="Pone el flag en FALSE (rollback) en vez de TRUE.") p.add_argument("--run-id", default=None, help="run_id de script_audit (recomendado con --apply).") p.add_argument("--json", action="store_true", help="Imprime el resultado como JSON.") args = p.parse_args() dry_run = not args.apply run_id = args.run_id if args.apply and not run_id: run_id = uuid.uuid4().hex script_audit.create_run(run_id, "enable_duplicate_opportunity", arguments=f"location={args.location} disable={args.disable}", locations=[args.location]) print(f"run_id generado: {run_id}") try: res = run(location_id=args.location, target_value=(not args.disable), dry_run=dry_run, run_id=run_id) if args.apply and run_id: script_audit.update_run_status(run_id, "completed") except Exception as e: print(f"ERROR: {e}") if args.apply and run_id: try: script_audit.update_run_status(run_id, "failed", str(e)) except Exception: pass sys.exit(2) if args.json: print(json.dumps(res, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()