meta-optimizer-formacion/airtable_client.py
José Manuel Gómez 9239e2f67f Initial scaffold: Meta Optimizer for RoiFormacion campaigns
Ports meta-optimizer's Meta Ads execution/approval/creative-analysis layer
(agent.py, meta_ads_client.py, baserow_client.py, slack_notifier.py,
approval_server.py) and replaces the per-vertical CPL model with the
PPL + monthly-capping-per-course model already used by leads-optimizer,
via a new airtable_client.py that shares Cursos/Familias/CentroCurso/
CursoMes/Leads Lake with that project and adds Meta Ads Campaigns /
MetaCampaignMes alongside its Google Ads Campaigns / GACampaignMes.
2026-07-07 16:53:03 +02:00

348 lines
15 KiB
Python

"""
Client for the shared Airtable base (same base used by leads-optimizer).
Reused as-is from leads-optimizer: Cursos / CentroCurso / CursoMes / Leads Lake
are READ-ONLY here — the course catalog, PPL per center and monthly capping are
maintained externally (manually, or by leads-optimizer). This client only
creates/updates "Meta Ads Campaigns" and "MetaCampaignMes", the Meta-specific
tables that sit alongside "Google Ads Campaigns" / "GACampaignMes".
"""
import re
from datetime import datetime
from pyairtable import Api
import config
MESES_ES = {
1: "Enero", 2: "Febrero", 3: "Marzo", 4: "Abril",
5: "Mayo", 6: "Junio", 7: "Julio", 8: "Agosto",
9: "Septiembre", 10: "Octubre", 11: "Noviembre", 12: "Diciembre",
}
def extract_cursoid(campaign_name: str) -> str | None:
"""
RoiFormacion_884_Curso_Desarrollador_leadads -> "884"
RoiFormacion_1281Instaladores_24_leadads -> "1281" (sin guion bajo tras el id)
Tolera también la variante con tilde (RoiFormación) por si reaparece.
"""
m = re.search(r'roiformaci[oó]n_?(\d+)', campaign_name, re.IGNORECASE)
return m.group(1) if m else None
class AirtableClient:
def __init__(self):
self.api = Api(config.AIRTABLE_TOKEN)
self.leads = self.api.table(config.AIRTABLE_BASE_ID, config.LEADS_TABLE)
self.cursos = self.api.table(config.AIRTABLE_BASE_ID, config.CURSOS_TABLE)
self.centrocurso = self.api.table(config.AIRTABLE_BASE_ID, config.CENTROCURSO_TABLE)
self.cursomes = self.api.table(config.AIRTABLE_BASE_ID, config.CURSOMES_TABLE)
self.campaigns = self.api.table(config.AIRTABLE_BASE_ID, config.META_CAMPAIGNS_TABLE)
self.metacampaignmes = self.api.table(config.AIRTABLE_BASE_ID, config.META_CAMPAIGNMES_TABLE)
# ------------------------------------------------------------------ #
# Lookups de negocio (PPL, capping, familia) — solo lectura #
# ------------------------------------------------------------------ #
def build_campaign_lookups(self, as_of_date: str = None) -> tuple[dict, dict, dict]:
"""
3 llamadas bulk, igual que leads-optimizer, más el lookup de Familia
(no existe en leads-optimizer porque Google no lo necesitaba para nada
operativo; aquí sustituye al concepto de "vertical" de Viviful).
as_of_date (YYYY-MM-DD): usa el capping del mes de esa fecha en vez del
mes en curso (lo usa backfill.py para reconstruir estados pasados).
Devuelve (ppl_lookup, cap_lookup, familia_lookup), todos keyed por
cursoid_text.
"""
ref = datetime.strptime(as_of_date, "%Y-%m-%d") if as_of_date else datetime.now()
mes_nombre = MESES_ES[ref.month]
anio_str = str(ref.year)
cc_records = self.centrocurso.all(
formula="{Estado ROI}='ABIERTO'",
fields=["PPL", "% invalidación (from Centros)"],
)
cc_data = {}
for r in cc_records:
ppl_val = float(r["fields"].get("PPL") or 0)
pct_raw = r["fields"].get("% invalidación (from Centros)", 0)
pct = float(pct_raw[0]) if isinstance(pct_raw, list) and pct_raw else float(pct_raw or 0)
cc_data[r["id"]] = {"ppl": ppl_val, "pct": pct}
cursos_records = self.cursos.all(
fields=["CursoID", "CentroCurso", "Familia (from Familias)"]
)
curso_by_recordid = {}
curso_to_cc = {}
familia_lookup = {}
for r in cursos_records:
cid = r["fields"].get("CursoID")
if cid is None:
continue
cursoid_text = str(int(cid))
curso_by_recordid[r["id"]] = cursoid_text
curso_to_cc[cursoid_text] = r["fields"].get("CentroCurso", [])
familia = r["fields"].get("Familia (from Familias)")
familia_lookup[cursoid_text] = familia[0] if isinstance(familia, list) and familia else (familia or "Sin familia")
ppl_lookup = {}
for cursoid_text, cc_ids in curso_to_cc.items():
total_ppl = sum(
cc_data[cc_id]["ppl"] * cc_data[cc_id]["pct"]
for cc_id in cc_ids if cc_id in cc_data
)
ppl_lookup[cursoid_text] = round(total_ppl, 2)
cap_formula = f"AND({{Mes}}='{mes_nombre}',{{Año}}='{anio_str}')"
cursomes_records = self.cursomes.all(formula=cap_formula, fields=["CursoID", "Caping Admitido"])
cap_lookup = {}
for r in cursomes_records:
cap = int(r["fields"].get("Caping Admitido") or 0)
for curso_rec_id in r["fields"].get("CursoID", []):
cursoid_text = curso_by_recordid.get(curso_rec_id)
if cursoid_text:
cap_lookup[cursoid_text] = cap
return ppl_lookup, cap_lookup, familia_lookup
def get_leads_this_month_meta(self, campaign_name: str, as_of_date: str = None) -> tuple[int, list[str]]:
"""
Leads acumulados en el mes atribuidos a un curso vía Meta Lead Ads,
hasta as_of_date (YYYY-MM-DD) inclusive, o hasta hoy si no se indica
(as_of_date lo usa backfill.py para reconstruir el estado histórico
del mes en una fecha pasada).
Los leads de Meta ya llegan a Leads Lake con attr_utm_source='Lead ads'
y attr_cursoid resuelto (confirmado con datos reales) — a diferencia de
Google, aquí solo hay una vía de atribución, no cinco.
"""
course_num = extract_cursoid(campaign_name)
if not course_num:
return 0, []
ref = datetime.strptime(as_of_date, "%Y-%m-%d") if as_of_date else datetime.now()
mes_inicio = f"{ref.year}-{ref.month:02d}-01"
fin_clause = f",{{creado}}<'{(ref).strftime('%Y-%m-%d')}T23:59:59.999Z'" if as_of_date else ""
formula = (
f"AND({{attr_utm_source}}='Lead ads',"
f"{{attr_cursoid}}='{course_num}',"
f"{{creado}}>='{mes_inicio}'{fin_clause})"
)
records = self.leads.all(formula=formula, fields=["attr_cursoid"])
ids = [r["id"] for r in records]
return len(ids), ids
# ------------------------------------------------------------------ #
# Meta Ads Campaigns (catálogo) #
# ------------------------------------------------------------------ #
def sync_campaigns_from_meta_ads(self, meta_campaigns: list[dict], ppl_lookup: dict = None) -> dict:
"""
Sincroniza el catálogo de campañas de Meta -> Airtable.
meta_campaigns: [{id, name, status}] (status: "ACTIVE"|"PAUSED"|...)
A diferencia de "Google Ads Campaigns" (donde 'CursoID Text' ya existe
como campo pre-poblado), aquí lo calculamos y escribimos nosotros mismos
al crear la tabla desde cero.
"""
STATUS_MAP = {"ACTIVE": "Activa"}
all_records = self.campaigns.all()
at_by_cid = {
str(r["fields"].get("CampaignID", "")).strip(): r
for r in all_records if r["fields"].get("CampaignID")
}
created, updated = [], []
to_create, to_update = [], []
for mc in meta_campaigns:
cid = mc["id"]
at_status = STATUS_MAP.get(mc["status"], "Pausada")
at_record = at_by_cid.get(cid)
cursoid_text = extract_cursoid(mc["name"]) or ""
ppl = round((ppl_lookup or {}).get(cursoid_text, 0), 2)
if at_record is None:
fields = {
"Campaign Name": mc["name"],
"CampaignID": cid,
"Status": at_status,
"CursoID Text": cursoid_text,
}
if ppl_lookup is not None:
fields["PPL"] = ppl
to_create.append(fields)
created.append({"name": mc["name"], "id": cid, "status": at_status})
else:
changes = {}
if at_record["fields"].get("Campaign Name") != mc["name"]:
changes["Campaign Name"] = mc["name"]
if at_record["fields"].get("Status") != at_status:
changes["Status"] = at_status
if at_record["fields"].get("CursoID Text") != cursoid_text:
changes["CursoID Text"] = cursoid_text
if ppl_lookup is not None and at_record["fields"].get("PPL") != ppl:
changes["PPL"] = ppl
if changes:
to_update.append((at_record["id"], changes))
updated.append({"name": mc["name"], "id": cid, "changes": changes})
for i in range(0, len(to_create), 10):
self.campaigns.batch_create(to_create[i:i + 10])
for i in range(0, len(to_update), 10):
batch = [{"id": rid, "fields": changes} for rid, changes in to_update[i:i + 10]]
self.campaigns.batch_update(batch)
return {"created": created, "updated": updated, "at_by_cid": at_by_cid}
# ------------------------------------------------------------------ #
# MetaCampaignMes (estado mensual) #
# ------------------------------------------------------------------ #
def sync_metacampaignmes(
self,
meta_campaigns: list[dict],
monthly_metrics: dict,
ppl_lookup: dict,
cap_lookup: dict,
at_by_cid: dict,
) -> dict:
"""
Crea/actualiza MetaCampaignMes para el mes/año en curso.
monthly_metrics: {campaign_id: {spend, leads, ...}} mes-a-la-fecha
(viene de meta.get_campaign_metrics(inicio_mes, ayer)).
"""
STATUS_MAP = {"ACTIVE": "Activa"}
now = datetime.now()
mes_num, anio_str = str(now.month), str(now.year)
formula = f"AND({{Mes}}='{mes_num}',{{Año}}='{anio_str}')"
existing = self.metacampaignmes.all(formula=formula)
mcm_by_at_cid = {}
for r in existing:
at_cids = r["fields"].get("CampaignID", [])
if at_cids:
mcm_by_at_cid[at_cids[0]] = r
to_create, to_update = [], []
for mc in meta_campaigns:
cid = mc["id"]
at_record = at_by_cid.get(cid)
if not at_record:
continue
at_cid = at_record["id"]
cursoid_text = extract_cursoid(mc["name"]) or ""
metrics = (monthly_metrics or {}).get(cid, {})
conv = round(metrics.get("leads", 0), 2)
cost = round(metrics.get("spend", 0), 2)
ppl = round((ppl_lookup or {}).get(cursoid_text, 0), 2)
cpa_max = round(ppl * 0.70, 2)
cap = int((cap_lookup or {}).get(cursoid_text, 0))
at_status = STATUS_MAP.get(mc["status"], "Pausada")
mcm_record = mcm_by_at_cid.get(at_cid)
if mcm_record is None:
to_create.append({
"CampaignID": [at_cid],
"Mes": mes_num,
"Año": anio_str,
"PPL": ppl,
"CPAMax": cpa_max,
"CapTotalMes": cap,
"CosteMes": cost,
"ConvMes": conv,
"Status": at_status,
})
else:
f = mcm_record["fields"]
changes = {}
if f.get("CosteMes") != cost:
changes["CosteMes"] = cost
if f.get("ConvMes") != conv:
changes["ConvMes"] = conv
if f.get("PPL") != ppl:
changes["PPL"] = ppl
if f.get("CPAMax") != cpa_max:
changes["CPAMax"] = cpa_max
if f.get("CapTotalMes") != cap:
changes["CapTotalMes"] = cap
if f.get("Status") != at_status:
changes["Status"] = at_status
if changes:
to_update.append((mcm_record["id"], changes))
for i in range(0, len(to_create), 10):
self.metacampaignmes.batch_create(to_create[i:i + 10], typecast=True)
for i in range(0, len(to_update), 10):
batch = [{"id": rid, "fields": f} for rid, f in to_update[i:i + 10]]
self.metacampaignmes.batch_update(batch, typecast=True)
return {"created": len(to_create), "updated": len(to_update)}
def get_active_metacampaignmes(self) -> list[dict]:
"""Lee MetaCampaignMes del mes/año en curso, resolviendo el Meta campaign ID."""
now = datetime.now()
mes_num, anio_str = str(now.month), str(now.year)
campaigns_records = self.campaigns.all(fields=["CampaignID"])
at_id_to_cid, cid_to_at_id = {}, {}
for r in campaigns_records:
cid = str(r["fields"].get("CampaignID", "")).strip()
if cid:
at_id_to_cid[r["id"]] = cid
cid_to_at_id[cid] = r["id"]
formula = f"AND({{Mes}}='{mes_num}',{{Año}}='{anio_str}')"
records = self.metacampaignmes.all(formula=formula)
result = []
for r in records:
f = r["fields"]
at_cids = f.get("CampaignID", [])
cid = at_id_to_cid.get(at_cids[0], "") if at_cids else ""
if not cid:
continue
result.append({
"airtable_id": r["id"],
"campaign_at_id": cid_to_at_id.get(cid, ""),
"meta_campaign_id": cid,
"ppl": float(f.get("PPL") or 0),
"capping_mensual": int(f.get("CapTotalMes") or 0),
"cpa_maximo": float(f.get("CPAMax") or 0),
"conv_mes": float(f.get("ConvMes") or 0),
})
return result
def batch_update_status(self, updates: list[tuple[str, str, str]]) -> None:
"""updates: [(mcm_record_id, campaign_at_id, meta_status)], meta_status: 'ACTIVE'|'PAUSED'."""
STATUS_MAP = {"ACTIVE": "Activa"}
mcm_batch, cat_batch = [], []
for mcm_id, cat_id, meta_status in updates:
at_status = STATUS_MAP.get(meta_status, "Pausada")
if mcm_id:
mcm_batch.append({"id": mcm_id, "fields": {"Status": at_status}})
if cat_id:
cat_batch.append({"id": cat_id, "fields": {"Status": at_status}})
for i in range(0, len(mcm_batch), 10):
self.metacampaignmes.batch_update(mcm_batch[i:i + 10])
for i in range(0, len(cat_batch), 10):
self.campaigns.batch_update(cat_batch[i:i + 10])
def batch_update_metacampaignmes_advice(self, updates: list[tuple[str, str, str, str]]) -> None:
"""updates: [(mcm_record_id, consejo, criticidad, log_text)]."""
batch = [
{"id": rid, "fields": {"Consejo": consejo, "Criticidad": criticidad, "Log": log_text}}
for rid, consejo, criticidad, log_text in updates
]
for i in range(0, len(batch), 10):
self.metacampaignmes.batch_update(batch[i:i + 10], typecast=True)
def batch_update_metacampaignmes_final_leads(self, updates: list[tuple[str, int]]) -> None:
"""updates: [(mcm_record_id, leads_lake_count)] -> ConvLeadsLakeMesFinal."""
batch = [{"id": rid, "fields": {"ConvLeadsLakeMesFinal": leads}} for rid, leads in updates]
for i in range(0, len(batch), 10):
self.metacampaignmes.batch_update(batch[i:i + 10], typecast=True)