Primer commit
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""sync_forms_brand.py
|
||||
|
||||
Sincroniza el catalogo de formularios y todos los submissions de la cuenta de
|
||||
Marca Principal (GbKkBpCmKu2QmloKFHy3) hacia la base de datos local
|
||||
(mp_manager.sqlite). El audit_brand_sucursal_vs_form.py consume estos datos.
|
||||
|
||||
Por que solo Marca:
|
||||
En Monte Providencia los formularios publicos viven en la cuenta de Marca
|
||||
y todos los envios pasan por ahi, sin importar la sucursal destino. Por eso
|
||||
este script ignora sucursales por defecto. Usa --location para forzar otra.
|
||||
|
||||
Paginacion:
|
||||
GET /forms/submissions acepta page (default 1), limit (max 100) y
|
||||
opcionalmente startAt / endAt (formato 'YYYY-MM-DD' o ISO 8601). El cliente
|
||||
itera hasta agotar meta.nextPage, deduplicando por id.
|
||||
|
||||
Backfill historico:
|
||||
SIN startAt/endAt, GHL solo devuelve ~1 mes hacia atras desde la fecha
|
||||
actual. Para histórico completo hay que pedir ventanas mensuales. Este
|
||||
script automatiza eso con --backfill-months N: parte la peticion en
|
||||
ventanas mensuales (mes 1, mes 2, ...) y acumula todo en SQLite.
|
||||
|
||||
Uso:
|
||||
python scripts/sync_forms_brand.py # sync incremental (mes corriente)
|
||||
python scripts/sync_forms_brand.py --backfill-months 12 # ultimo año por ventanas mensuales
|
||||
python scripts/sync_forms_brand.py --start-at 2025-09-01 # rango explicito desde fecha
|
||||
python scripts/sync_forms_brand.py --start-at 2025-09-01 --end-at 2025-12-31
|
||||
python scripts/sync_forms_brand.py --max 200 --quiet
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
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 db # noqa: E402
|
||||
import sync_engine # noqa: E402
|
||||
|
||||
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
|
||||
|
||||
# Field IDs historicos para el campo "Sucursal" en el formulario de Marca.
|
||||
# Cuando se rediseña el form, GHL asigna un id nuevo al mismo campo conceptual.
|
||||
# El primary se resuelve dinamicamente del schema del contacto; estos fallbacks
|
||||
# cubren versiones viejas del form que ya no estan en el schema actual.
|
||||
HISTORICAL_SUCURSAL_FIELD_IDS = {
|
||||
BRAND_LOCATION_ID: ["ZVMoW8J0MqZNh3fEZ7Uu"],
|
||||
}
|
||||
|
||||
|
||||
def norm_field_name(name):
|
||||
if not name:
|
||||
return ""
|
||||
t = unicodedata.normalize("NFKD", str(name).strip().lower())
|
||||
t = "".join(ch for ch in t if not unicodedata.combining(ch))
|
||||
return " ".join(t.split())
|
||||
|
||||
|
||||
def resolve_sucursal_field_id(token, location_id):
|
||||
"""Busca el field id del campo 'Sucursal' en el schema de contactos de la
|
||||
location. Es el mismo id que aparece como key dentro de others en los
|
||||
submissions, asi que vale para extraer el valor sin hardcodear."""
|
||||
schema = sync_engine.ghl_client.get_object_schema(token, location_id, "contact")
|
||||
for name, fid in schema.items():
|
||||
if norm_field_name(name) == "sucursal":
|
||||
return fid, name
|
||||
return None, None
|
||||
|
||||
|
||||
def parse_date_str(value):
|
||||
"""Acepta YYYY-MM-DD o ISO 8601. Devuelve string normalizado para GHL."""
|
||||
if not value:
|
||||
return None
|
||||
v = value.strip()
|
||||
if re.match(r"^\d{4}-\d{2}-\d{2}$", v):
|
||||
return v # GHL acepta YYYY-MM-DD directo
|
||||
if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", v):
|
||||
return v
|
||||
raise SystemExit(f"Fecha invalida {value!r}. Usa YYYY-MM-DD o ISO 8601.")
|
||||
|
||||
|
||||
def first_of_month(d):
|
||||
return d.replace(day=1)
|
||||
|
||||
|
||||
def add_months(d, months):
|
||||
"""Suma N meses a un date (sin dependencia externa)."""
|
||||
y = d.year + (d.month - 1 + months) // 12
|
||||
m = (d.month - 1 + months) % 12 + 1
|
||||
# day=1 para evitar errores como feb 30
|
||||
return d.replace(year=y, month=m, day=1)
|
||||
|
||||
|
||||
def build_windows(start_at, end_at, backfill_months):
|
||||
"""Devuelve lista de (start_str, end_str) en orden cronologico.
|
||||
|
||||
Reglas:
|
||||
- Si hay --start-at y/o --end-at explicitos, se usa esa ventana (una sola).
|
||||
- Si hay --backfill-months N, se generan N ventanas mensuales hasta hoy.
|
||||
- Si no hay nada, devuelve [(None, None)] = comportamiento default (sin filtro).
|
||||
"""
|
||||
if start_at or end_at:
|
||||
return [(start_at, end_at)]
|
||||
if backfill_months and backfill_months > 0:
|
||||
today = date.today()
|
||||
# Empezamos backfill_months atras (al dia 1 de ese mes) y avanzamos
|
||||
# mes a mes hasta cubrir hoy.
|
||||
start = add_months(first_of_month(today), -backfill_months + 1)
|
||||
windows = []
|
||||
cursor = start
|
||||
for _ in range(backfill_months + 1):
|
||||
end = add_months(cursor, 1)
|
||||
# ventana semicerrada [cursor, end)
|
||||
windows.append((cursor.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")))
|
||||
cursor = end
|
||||
if cursor > today:
|
||||
break
|
||||
return windows
|
||||
return [(None, None)]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--location", default=BRAND_LOCATION_ID,
|
||||
help=f"Location de la que sincronizar formularios. Default: Marca ({BRAND_LOCATION_ID})")
|
||||
parser.add_argument("--max", type=int, default=None,
|
||||
help="Tope de submissions por formulario por ventana. Default: todos.")
|
||||
parser.add_argument("--page-size", type=int, default=100,
|
||||
help="Tamano de pagina (max 100). Default 100.")
|
||||
parser.add_argument("--start-at", help="Fecha desde (YYYY-MM-DD o ISO). Filtra createdAt.")
|
||||
parser.add_argument("--end-at", help="Fecha hasta (YYYY-MM-DD o ISO). Filtra createdAt.")
|
||||
parser.add_argument("--backfill-months", type=int, default=0,
|
||||
help="Backfill historico por N ventanas mensuales hacia atras. "
|
||||
"Ignora si se pasa --start-at/--end-at.")
|
||||
parser.add_argument("--reextract-only", action="store_true",
|
||||
help="No descarga nada de GHL: solo re-procesa sucursal_value en "
|
||||
"los submissions ya guardados usando los field_ids actual + historicos.")
|
||||
parser.add_argument("--quiet", action="store_true", help="Reduce logs.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
if args.page_size < 1 or args.page_size > 100:
|
||||
raise SystemExit("--page-size debe estar entre 1 y 100")
|
||||
if args.backfill_months and args.backfill_months < 0:
|
||||
raise SystemExit("--backfill-months debe ser >= 0")
|
||||
|
||||
start_at = parse_date_str(args.start_at)
|
||||
end_at = parse_date_str(args.end_at)
|
||||
windows = build_windows(start_at, end_at, args.backfill_months)
|
||||
|
||||
accounts = sync_engine.parse_accounts_csv()
|
||||
account = next((a for a in accounts if a["location_id"] == args.location), None)
|
||||
if not account:
|
||||
raise SystemExit(f"Location {args.location} no esta en el CSV de tokens.")
|
||||
|
||||
token = account["token"]
|
||||
location_id = account["location_id"]
|
||||
print(f"=== SYNC FORMS: {account['nombre']} ({location_id}) ===")
|
||||
print(f" Ventanas de fecha a consultar: {len(windows)}")
|
||||
if len(windows) <= 3:
|
||||
for s, e in windows:
|
||||
print(f" [{s or '(sin filtro)'} -> {e or '(sin filtro)'}]")
|
||||
|
||||
sucursal_fid, sucursal_name = resolve_sucursal_field_id(token, location_id)
|
||||
historical_fids = HISTORICAL_SUCURSAL_FIELD_IDS.get(location_id, [])
|
||||
sucursal_field_ids = []
|
||||
if sucursal_fid:
|
||||
sucursal_field_ids.append(sucursal_fid)
|
||||
print(f" Campo Sucursal actual: '{sucursal_name}' -> {sucursal_fid}")
|
||||
else:
|
||||
print(" WARN: no encontre el campo Sucursal actual en el schema de contactos.")
|
||||
for hfid in historical_fids:
|
||||
if hfid not in sucursal_field_ids:
|
||||
sucursal_field_ids.append(hfid)
|
||||
if historical_fids:
|
||||
print(f" Field_ids historicos de Sucursal a tolerar: {historical_fids}")
|
||||
|
||||
# Modo re-extract: no hay sync nuevo, solo re-procesar registros existentes
|
||||
if args.reextract_only:
|
||||
if not sucursal_field_ids:
|
||||
raise SystemExit("--reextract-only sin field_ids candidatos. Abortando.")
|
||||
updated, total = db.reextract_sucursal_values(location_id, sucursal_field_ids)
|
||||
print(f"\n=== RE-EXTRACT ===")
|
||||
print(f" Submissions procesados: {total}")
|
||||
print(f" Con sucursal poblada: {updated}")
|
||||
print(f" Sin sucursal extraible: {total - updated}")
|
||||
return
|
||||
|
||||
print("\n[1/2] Descargando catalogo de formularios...")
|
||||
forms = sync_engine.ghl_client.get_forms(token, location_id)
|
||||
print(f" Formularios: {len(forms)}")
|
||||
for f in forms:
|
||||
print(f" - {f.get('id')} {f.get('name')!r}")
|
||||
db.save_forms(location_id, forms)
|
||||
|
||||
if not forms:
|
||||
print("\nNo hay formularios. Nada que sincronizar.")
|
||||
return
|
||||
|
||||
print("\n[2/2] Descargando submissions...")
|
||||
total_synced = 0
|
||||
seen_ids_global = set() # dedup cross-window
|
||||
for form in forms:
|
||||
form_id = form.get("id")
|
||||
form_name = form.get("name") or form_id
|
||||
|
||||
for win_start, win_end in windows:
|
||||
window_label = f"{win_start or 'open'}->{win_end or 'open'}"
|
||||
|
||||
def progress(page, page_count, accum, _form_name=form_name, _wl=window_label):
|
||||
if args.quiet:
|
||||
return
|
||||
print(f" {_form_name} [{_wl}] page {page}: +{page_count} (acumulado {accum})")
|
||||
|
||||
subs = sync_engine.ghl_client.get_all_form_submissions(
|
||||
token, location_id,
|
||||
form_id=form_id,
|
||||
page_size=args.page_size,
|
||||
max_submissions=args.max,
|
||||
start_at=win_start,
|
||||
end_at=win_end,
|
||||
progress_callback=progress,
|
||||
)
|
||||
# Dedup cross-window: el mismo submission puede aparecer en dos
|
||||
# ventanas si las fechas se traslapan o si la API tiene jitter.
|
||||
unique = [s for s in subs if s.get("id") and s["id"] not in seen_ids_global]
|
||||
for s in unique:
|
||||
seen_ids_global.add(s["id"])
|
||||
|
||||
if not unique:
|
||||
if not args.quiet:
|
||||
print(f" {form_name} [{window_label}]: 0 nuevos")
|
||||
continue
|
||||
|
||||
n_saved = db.upsert_form_submissions(location_id, unique, sucursal_field_id=sucursal_field_ids)
|
||||
total_synced += n_saved
|
||||
print(f" {form_name} [{window_label}]: {n_saved} nuevos guardados (descargados {len(subs)}, unicos {len(unique)})")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
db_total = db.count_form_submissions(location_id)
|
||||
print(f"DONE. Submissions en SQLite para {location_id}: {db_total} (sync de hoy aporto {total_synced})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user