invy/backend/whatsapp_templates.py
2026-05-14 11:53:17 +03:00

335 lines
14 KiB
Python

"""
WhatsApp Template Registry
--------------------------
Single source of truth for ALL approved Meta WhatsApp templates.
How to add a new template:
1. Get the template approved in Meta Business Manager.
2. Add an entry under TEMPLATES with:
- meta_name : exact name as it appears in Meta
- language_code : he / he_IL / en / en_US …
- friendly_name : shown in the frontend dropdown
- description : optional, for documentation
- header_type : "TEXT" or "IMAGE" or "VIDEO" or "DOCUMENT" (default: "TEXT")
- header_params : ordered list of variable keys sent in the HEADER component
(empty list [] if the template has no header variables)
- header_handle : media handle for IMAGE/VIDEO/DOCUMENT headers (optional)
- body_params : ordered list of variable keys sent in the BODY component
- button_type : "URL" or "PHONE_NUMBER" or "QUICK_REPLY" (optional)
- button_text : button label/text (optional)
- button_url : button URL (optional, for URL buttons)
- fallbacks : dict {key: default_string} used when the caller doesn't
provide a value for that key
The backend will:
- Look up the template by its registry key (e.g. "wedding_invitation")
- Build the Meta payload header/body param lists in exact declaration order
- Apply fallbacks for any missing keys
- Validate total param count == len(header_params) + len(body_params)
IMPORTANT: param order in header_params / body_params MUST match the
{{1}}, {{2}}, … placeholder order inside the Meta template.
"""
import json
import os
from typing import Dict, Any, Optional
from sqlalchemy.orm import Session
def load_custom_templates(db: Session) -> Dict[str, Dict[str, Any]]:
"""Load user-created templates from the database."""
from models import WhatsAppTemplate
try:
templates = db.query(WhatsAppTemplate).all()
result = {}
for t in templates:
result[t.template_key] = {
"meta_name": t.meta_name,
"language_code": t.language_code,
"friendly_name": t.friendly_name,
"description": t.description,
"header_type": t.header_type,
"header_text": t.header_text,
"header_handle": t.header_handle,
"body_text": t.body_text,
"header_params": json.loads(t.header_params),
"body_params": json.loads(t.body_params),
"button_type": t.button_type,
"button_text": t.button_text,
"button_url": t.button_url,
"button_param_key": t.button_url, # backward compat
"fallbacks": json.loads(t.fallbacks),
"guest_name_key": "",
}
return result
except Exception as e:
print(f"[WARNING] Failed to load custom templates from database: {e}")
return {}
def save_custom_templates(db: Session, data: Dict[str, Dict[str, Any]]) -> None:
"""Persist custom templates to the database."""
from models import WhatsAppTemplate
try:
# Clear old templates
db.query(WhatsAppTemplate).delete()
# Add new ones
for key, tpl in data.items():
template_record = WhatsAppTemplate(
template_key=key,
meta_name=tpl.get("meta_name", key),
friendly_name=tpl.get("friendly_name", key),
language_code=tpl.get("language_code", "he"),
description=tpl.get("description", ""),
header_type=tpl.get("header_type", "TEXT"),
header_text=tpl.get("header_text", ""),
header_handle=tpl.get("header_handle", ""),
body_text=tpl.get("body_text", ""),
header_params=json.dumps(tpl.get("header_params", [])),
body_params=json.dumps(tpl.get("body_params", [])),
button_type=tpl.get("button_type", ""),
button_text=tpl.get("button_text", ""),
button_url=tpl.get("button_url", ""),
fallbacks=json.dumps(tpl.get("fallbacks", {})),
)
db.add(template_record)
db.commit()
except Exception as e:
print(f"[WARNING] Failed to save custom templates to database: {e}")
db.rollback()
def get_all_templates(db: Session) -> Dict[str, Dict[str, Any]]:
"""Return merged dict: built-in TEMPLATES + user custom templates."""
merged = dict(TEMPLATES)
custom = load_custom_templates(db)
merged.update(custom)
# Debug logging
import logging
logger = logging.getLogger(__name__)
if custom:
logger.info(f"[Templates] Loaded {len(custom)} custom templates from database: {list(custom.keys())}")
logger.debug(f"[Templates] All available templates: {list(merged.keys())}")
return merged
def add_custom_template(db: Session, key: str, template: Dict[str, Any]) -> None:
"""Add or overwrite a custom template (cannot replace built-ins)."""
if key in TEMPLATES:
raise ValueError(f"Template key '{key}' is a built-in and cannot be overwritten.")
data = load_custom_templates(db)
data[key] = template
save_custom_templates(db, data)
def delete_custom_template(db: Session, key: str) -> None:
"""Delete a custom template by key. Raises KeyError if not found."""
if key in TEMPLATES:
raise ValueError(f"Template key '{key}' is a built-in and cannot be deleted.")
data = load_custom_templates(db)
if key not in data:
raise KeyError(f"Custom template '{key}' not found.")
del data[key]
save_custom_templates(db, data)
# ── Template registry ─────────────────────────────────────────────────────────
TEMPLATES: Dict[str, Dict[str, Any]] = {
# ── wedding_invitation ────────────────────────────────────────────────────
# Approved Hebrew wedding invitation template.
# Header {{1}} = guest name (greeting)
# Body {{1}} = guest name (same, repeated inside body)
# Body {{2}} = groom name
# Body {{3}} = bride name
# Body {{4}} = venue / hall name
# Body {{5}} = event date (DD/MM)
# Body {{6}} = event time (HH:mm)
# Body {{7}} = RSVP / guest link URL
# Button {{1}} = event_id (dynamic URL parameter)
"wedding_invitation": {
"meta_name": "wedding_invitation",
"language_code": "he",
"friendly_name": "הזמנה לחתונה",
"description": "הזמנה רשמית לאירוע חתונה עם כל פרטי האירוע וקישור RSVP",
"header_params": ["contact_name"], # 1 header variable
"body_params": [ # 7 body variables
"contact_name", # body {{1}}
"groom_name", # body {{2}}
"bride_name", # body {{3}}
"venue", # body {{4}}
"event_date", # body {{5}}
"event_time", # body {{6}}
"guest_link", # body {{7}}
],
"button_type": "URL",
"button_text": "הצבע על הזמנה",
"button_url": "https://invy.dvirlabs.com/guest/{{1}}",
"button_param_key": "event_id",
"form_params": [ # All params shown in form
"contact_name",
"groom_name",
"bride_name",
"venue",
"event_date",
"event_time",
"guest_link",
"event_id", # Button param - shown in form but NOT sent as body parameter
],
"fallbacks": {
"contact_name": "חבר",
"groom_name": "החתן",
"bride_name": "הכלה",
"venue": "האולם",
"event_date": "",
"event_time": "",
"guest_link": "https://invy.dvirlabs.com/guest",
"event_id": "event-id",
},
},
# ── save_the_date ─────────────────────────────────────────────────────────
# Shorter "save the date" template — no venue/time details.
# Create & approve this template in Meta before using it.
# Header {{1}} = guest name
# Body {{1}} = guest name (repeated)
# Body {{2}} = groom name
# Body {{3}} = bride name
# Body {{4}} = event date (DD/MM/YYYY)
# Body {{5}} = guest link
"save_the_date": {
"meta_name": "save_the_date",
"language_code": "he",
"friendly_name": "שמור את התאריך",
"description": "הודעת 'שמור את התאריך' קצרה לפני ההזמנה הרשמית",
"header_params": ["contact_name"],
"body_params": [
"contact_name",
"groom_name",
"bride_name",
"event_date",
"guest_link",
],
"fallbacks": {
"contact_name": "חבר",
"groom_name": "החתן",
"bride_name": "הכלה",
"event_date": "",
"guest_link": "https://invy.dvirlabs.com/guest",
},
},
# ── reminder_1 ────────────────────────────────────────────────────────────
# Reminder template sent ~1 week before the event.
# Header {{1}} = guest name
# Body {{1}} = guest name
# Body {{2}} = event date (DD/MM)
# Body {{3}} = event time (HH:mm)
# Body {{4}} = venue
# Body {{5}} = guest link
"reminder_1": {
"meta_name": "reminder_1",
"language_code": "he",
"friendly_name": "תזכורת לאירוע",
"description": "תזכורת שתשלח שבוע לפני האירוע",
"header_params": ["contact_name"],
"body_params": [
"contact_name",
"event_date",
"event_time",
"venue",
"guest_link",
],
"fallbacks": {
"contact_name": "חבר",
"event_date": "",
"event_time": "",
"venue": "האולם",
"guest_link": "https://invy.dvirlabs.com/guest",
},
},
}
# ── Helper functions ──────────────────────────────────────────────────────────
def get_template(db: Session, key: str) -> Dict[str, Any]:
"""
Return the template definition for *key* (checks both built-in + custom).
Raises KeyError with a helpful message if not found.
"""
all_tpls = get_all_templates(db)
if key not in all_tpls:
available = ", ".join(all_tpls.keys())
raise KeyError(
f"Unknown template key '{key}'. "
f"Available templates: {available}"
)
return all_tpls[key]
def list_templates_for_frontend(db: Session) -> list:
"""
Return a list suitable for the frontend dropdown (built-in + custom).
Each item: {key, friendly_name, meta_name, param_count, language_code, description, is_custom}
"""
all_tpls = get_all_templates(db)
custom_keys = set(load_custom_templates(db).keys())
return [
{
"key": key,
"friendly_name": tpl["friendly_name"],
"meta_name": tpl["meta_name"],
"language_code": tpl["language_code"],
"description": tpl.get("description", ""),
"param_count": len(tpl["header_params"]) + len(tpl["body_params"]),
"header_param_count": len(tpl["header_params"]),
"body_param_count": len(tpl["body_params"]),
"is_custom": key in custom_keys,
"body_params": tpl["body_params"],
"header_params": tpl["header_params"],
"body_text": tpl.get("body_text", ""),
"header_text": tpl.get("header_text", ""),
"header_type": tpl.get("header_type", "TEXT"),
"header_handle": tpl.get("header_handle", ""),
"button_type": tpl.get("button_type", ""),
"button_text": tpl.get("button_text", ""),
"button_url": tpl.get("button_url", ""),
"button_param_key": tpl.get("button_param_key", ""),
"form_params": tpl.get("form_params", tpl["body_params"]), # All params for form display
"guest_name_key": tpl.get("guest_name_key", ""),
"url_button": tpl.get("url_button", None),
}
for key, tpl in all_tpls.items()
]
def build_params_list(db: Session, key: str, values: dict) -> tuple:
"""
Given a template key and a dict of {param_key: value}, return
(header_params_list, body_params_list) after applying fallbacks.
Both lists contain plain string values in correct order.
"""
tpl = get_template(db, key) # checks built-in + custom
fallbacks = tpl.get("fallbacks", {})
def resolve(param_key: str) -> str:
raw = values.get(param_key, "")
val = str(raw).strip() if raw else ""
if not val:
val = str(fallbacks.get(param_key, "")).strip()
return val
header_values = [resolve(k) for k in tpl["header_params"]]
body_values = [resolve(k) for k in tpl["body_params"]]
return header_values, body_values