This commit is contained in:
debu-2 2026-10-14 05:38:10 +05:30
parent ad189b8568
commit dcae960928
5 changed files with 129 additions and 49 deletions

View file

@ -11,7 +11,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from .config import settings from .config import settings
from .models import UserAccount, WhatsAppBusinessAccount, WhatsAppMessage, WhatsAppMessageTemplate from .models import UserAccount, WhatsAppBusinessAccount, WhatsAppMessage, WhatsAppMessageTemplate, WhatsAppMetaConnection
from .whatsapp_onboarding import _graph, _graph_error_message, _owned_account, _quality_label, _token_app_id from .whatsapp_onboarding import _graph, _graph_error_message, _owned_account, _quality_label, _token_app_id
_NAME = re.compile(r"^[a-z0-9_]{1,512}$") _NAME = re.compile(r"^[a-z0-9_]{1,512}$")
@ -64,12 +64,59 @@ def _language_ok(code: str) -> bool:
return any(item[0] == code for item in LANGUAGES) return any(item[0] == code for item in LANGUAGES)
def _token(account: WhatsAppBusinessAccount) -> str: def _connection_method(connection: WhatsAppMetaConnection | None) -> str:
connection = account.connection if connection is None:
token = connection.access_token if connection else None return "WhatsApp"
if connection is None or not token or connection.status == "disconnected": if connection.granted_scopes == "manual":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Save the WhatsApp access token before managing templates.") return "Saved token"
return token if (connection.granted_scopes or "").strip():
return "Embedded signup"
return "WhatsApp"
def _stored_token(connection: WhatsAppMetaConnection | None) -> str | None:
if connection is None or connection.status == "disconnected":
return None
token = (connection.access_token or "").strip()
return token or None
def _candidate_tokens(db: Session, account: WhatsAppBusinessAccount) -> list[str]:
"""Embedded signup and a saved token are both stored on the connection."""
tokens: list[str] = []
own = _stored_token(account.connection)
if own:
tokens.append(own)
others = db.scalars(
select(WhatsAppMetaConnection).where(
WhatsAppMetaConnection.user_id == account.user_id,
WhatsAppMetaConnection.status != "disconnected",
)
).all()
for connection in others:
token = _stored_token(connection)
if token and token not in tokens:
tokens.append(token)
platform_waba = settings.whatsapp_business_account_id.strip()
platform_token = settings.whatsapp_access_token.strip()
if platform_token and account.waba_id == platform_waba and platform_token not in tokens:
tokens.append(platform_token)
return tokens
def _token(db: Session, account: WhatsAppBusinessAccount) -> str:
tokens = _candidate_tokens(db, account)
if not tokens:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Reconnect this WhatsApp account before managing templates.",
)
return tokens[0]
def _retry_with_another_token(exc: HTTPException) -> bool:
detail = str(exc.detail).lower()
return any(part in detail for part in ("access token", "oauth", "(#190)", "permission", "unsupported get", "(#10)"))
def _variables(text: str) -> list[str]: def _variables(text: str) -> list[str]:
@ -340,17 +387,26 @@ def _sent_counts(db: Session, user: UserAccount) -> dict[str, int]:
def _serialize(row: WhatsAppMessageTemplate, account: WhatsAppBusinessAccount | None, sent: int) -> dict[str, Any]: def _serialize(row: WhatsAppMessageTemplate, account: WhatsAppBusinessAccount | None, sent: int) -> dict[str, Any]:
phone = None phone = None
phones: list[str] = []
connection = account.connection if account is not None else None
if account is not None: if account is not None:
phone = next((item for item in account.phones if item.phone_number_id == row.phone_number_id), None) phone = next((item for item in account.phones if item.phone_number_id == row.phone_number_id), None)
if phone is None and account.phones: if phone is None and account.phones:
phone = account.phones[0] phone = account.phones[0]
for item in account.phones:
label = item.display_phone or item.verified_name or ""
if label and label not in phones:
phones.append(label)
return { return {
"id": str(row.id), "id": str(row.id),
"account_id": str(row.account_id), "account_id": str(row.account_id),
"account_name": account.name if account else "WhatsApp Business", "account_name": account.name if account else "WhatsApp Business",
"waba_name": account.name if account else "", "waba_name": account.name if account else "",
"connection_name": (connection.business_name if connection and connection.business_name else (account.name if account else "WhatsApp")),
"connection_method": _connection_method(connection),
"phone_number_id": row.phone_number_id, "phone_number_id": row.phone_number_id,
"display_phone": phone.display_phone if phone else "", "display_phone": phone.display_phone if phone else "",
"phones": phones,
"meta_template_id": row.meta_template_id, "meta_template_id": row.meta_template_id,
"name": row.name, "name": row.name,
"language": row.language, "language": row.language,
@ -433,15 +489,11 @@ async def _template_page(token: str, waba_id: str, params: dict[str, Any]) -> di
return {} return {}
async def sync_account_templates(db: Session, account: WhatsAppBusinessAccount) -> str | None: async def _pull_templates(db: Session, account: WhatsAppBusinessAccount, token: str) -> set[tuple[str, str]]:
token = account.connection.access_token if account.connection else None
if not token:
return "This account has no access token, so templates could not be refreshed."
seen: set[tuple[str, str]] = set() seen: set[tuple[str, str]] = set()
after = None after = None
try:
for _ in range(5): for _ in range(5):
params = {"limit": "100"} params: dict[str, Any] = {"limit": "100"}
if after: if after:
params["after"] = after params["after"] = after
data = await _template_page(token, account.waba_id, params) data = await _template_page(token, account.waba_id, params)
@ -455,9 +507,25 @@ async def sync_account_templates(db: Session, account: WhatsAppBusinessAccount)
after = cursors.get("after") if paging.get("next") else None after = cursors.get("after") if paging.get("next") else None
if not after: if not after:
break break
return seen
async def sync_account_templates(db: Session, account: WhatsAppBusinessAccount) -> str | None:
tokens = _candidate_tokens(db, account)
if not tokens:
if account.status == "disconnected":
return None
return "Reconnect this WhatsApp account to refresh its templates."
last_error: str | None = None
for index, token in enumerate(tokens):
try:
seen = await _pull_templates(db, account, token)
except HTTPException as exc: except HTTPException as exc:
db.rollback() db.rollback()
return str(exc.detail) last_error = str(exc.detail)
if _retry_with_another_token(exc) and index < len(tokens) - 1:
continue
return last_error
stored = db.scalars( stored = db.scalars(
select(WhatsAppMessageTemplate).where(WhatsAppMessageTemplate.waba_id == account.waba_id) select(WhatsAppMessageTemplate).where(WhatsAppMessageTemplate.waba_id == account.waba_id)
).all() ).all()
@ -468,6 +536,7 @@ async def sync_account_templates(db: Session, account: WhatsAppBusinessAccount)
account.templates_status = "active" account.templates_status = "active"
db.commit() db.commit()
return None return None
return last_error
async def list_managed_templates(db: Session, user: UserAccount, account_id: str | None) -> dict[str, Any]: async def list_managed_templates(db: Session, user: UserAccount, account_id: str | None) -> dict[str, Any]:
@ -556,7 +625,7 @@ def _validate_identity(payload: dict[str, Any]) -> tuple[str, str, str]:
async def create_managed_template(db: Session, user: UserAccount, payload: dict[str, Any]) -> dict[str, Any]: async def create_managed_template(db: Session, user: UserAccount, payload: dict[str, Any]) -> dict[str, Any]:
account = _owned_account(db, user, payload.get("account_id")) account = _owned_account(db, user, payload.get("account_id"))
token = _token(account) token = _token(db, account)
name, language, category = _validate_identity(payload) name, language, category = _validate_identity(payload)
existing = db.scalar( existing = db.scalar(
select(WhatsAppMessageTemplate).where( select(WhatsAppMessageTemplate).where(
@ -607,7 +676,7 @@ async def resubmit_managed_template(db: Session, user: UserAccount, template_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Meta has no id for this template yet.") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Meta has no id for this template yet.")
_validate_identity({**payload, "name": row.name, "language": row.language, "category": payload.get("category") or row.category}) _validate_identity({**payload, "name": row.name, "language": row.language, "category": payload.get("category") or row.category})
components = build_components({**payload, "name": row.name, "language": row.language}) components = build_components({**payload, "name": row.name, "language": row.language})
data = await _graph("POST", row.meta_template_id, token=_token(account), json_body={"components": components, "category": row.category}) data = await _graph("POST", row.meta_template_id, token=_token(db, account), json_body={"components": components, "category": row.category})
row.components = components row.components = components
row.definition = payload row.definition = payload
row.status = str(data.get("status") or "PENDING").upper() row.status = str(data.get("status") or "PENDING").upper()
@ -623,7 +692,7 @@ async def delete_managed_template(db: Session, user: UserAccount, template_id: A
params: dict[str, Any] = {"name": row.name} params: dict[str, Any] = {"name": row.name}
if row.meta_template_id: if row.meta_template_id:
params["hsm_id"] = row.meta_template_id params["hsm_id"] = row.meta_template_id
await _graph("DELETE", f"{account.waba_id}/message_templates", token=_token(account), params=params) await _graph("DELETE", f"{account.waba_id}/message_templates", token=_token(db, account), params=params)
db.delete(row) db.delete(row)
db.commit() db.commit()
return {"status": "deleted"} return {"status": "deleted"}
@ -631,7 +700,7 @@ async def delete_managed_template(db: Session, user: UserAccount, template_id: A
async def upload_header_media(db: Session, user: UserAccount, account_id: Any, filename: str, content_type: str, data: bytes) -> dict[str, str]: async def upload_header_media(db: Session, user: UserAccount, account_id: Any, filename: str, content_type: str, data: bytes) -> dict[str, str]:
account = _owned_account(db, user, account_id) account = _owned_account(db, user, account_id)
token = _token(account) token = _token(db, account)
kind = _MEDIA.get(content_type) kind = _MEDIA.get(content_type)
if kind is None: if kind is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload a JPEG, PNG, MP4, or PDF.") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload a JPEG, PNG, MP4, or PDF.")

View file

@ -78,7 +78,8 @@ export function WhatsAppTemplateDetailPage() {
<dl className="wa-tpl-review"> <dl className="wa-tpl-review">
<div><dt>Meta template ID</dt><dd>{row.meta_template_id || 'Not assigned yet'}</dd></div> <div><dt>Meta template ID</dt><dd>{row.meta_template_id || 'Not assigned yet'}</dd></div>
<div><dt>Account</dt><dd>{row.account_name}</dd></div> <div><dt>Account</dt><dd>{row.account_name}</dd></div>
<div><dt>Phone</dt><dd>{row.display_phone || 'Shared on the account'}</dd></div> <div><dt>Connection</dt><dd>{row.connection_name || row.account_name} · {row.connection_method || 'WhatsApp'}</dd></div>
<div><dt>Numbers</dt><dd>{(row.phones && row.phones.length ? row.phones : [row.display_phone]).filter(Boolean).join(', ') || 'Shared on the account'}</dd></div>
<div><dt>Category</dt><dd>{row.category.toLowerCase()}</dd></div> <div><dt>Category</dt><dd>{row.category.toLowerCase()}</dd></div>
<div><dt>Language</dt><dd>{row.language}</dd></div> <div><dt>Language</dt><dd>{row.language}</dd></div>
<div><dt>Quality</dt><dd>{row.quality || 'Not rated yet'}</dd></div> <div><dt>Quality</dt><dd>{row.quality || 'Not rated yet'}</dd></div>

View file

@ -56,7 +56,7 @@ export function WhatsAppTemplatesPage() {
}, []) }, [])
const filtered = useMemo(() => rows.filter((row) => { const filtered = useMemo(() => rows.filter((row) => {
const haystack = `${row.name} ${row.meta_template_id || ''}`.toLowerCase() const haystack = `${row.name} ${row.meta_template_id || ''} ${(row.phones || []).join(' ')} ${row.display_phone || ''} ${row.connection_name || ''} ${row.account_name || ''}`.toLowerCase()
if (search && !haystack.includes(search.toLowerCase())) return false if (search && !haystack.includes(search.toLowerCase())) return false
if (category && row.category !== category) return false if (category && row.category !== category) return false
if (language && row.language !== language) return false if (language && row.language !== language) return false
@ -129,7 +129,10 @@ export function WhatsAppTemplatesPage() {
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search by name or ID" aria-label="Search templates" /> <input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search by name or ID" aria-label="Search templates" />
<select value={accountId} onChange={(event) => { setAccountId(event.target.value); load(event.target.value) }} aria-label="WhatsApp account"> <select value={accountId} onChange={(event) => { setAccountId(event.target.value); load(event.target.value) }} aria-label="WhatsApp account">
<option value="">All accounts</option> <option value="">All accounts</option>
{accounts.map((account) => <option key={account.id} value={account.id}>{account.name}</option>)} {accounts.map((account) => {
const numbers = account.phones.map((phone) => phone.display_phone).filter(Boolean).join(', ')
return <option key={account.id} value={account.id}>{numbers ? `${account.name} · ${numbers}` : account.name}</option>
})}
</select> </select>
<select value={category} onChange={(event) => setCategory(event.target.value)} aria-label="Category"> <select value={category} onChange={(event) => setCategory(event.target.value)} aria-label="Category">
<option value="">Category</option> <option value="">Category</option>
@ -156,11 +159,13 @@ export function WhatsAppTemplatesPage() {
{!loading && filtered.length > 0 && ( {!loading && filtered.length > 0 && (
<div className="wa-tpl-table"> <div className="wa-tpl-table">
<div className="wa-tpl-row wa-tpl-head"> <div className="wa-tpl-row wa-tpl-head">
<span>Template name</span><span>Category</span><span>Language</span><span>Status</span><span>Quality</span><span>Messages sent</span><span>Last updated</span><span>Actions</span> <span>Template name</span><span>Number</span><span>Connection</span><span>Category</span><span>Language</span><span>Status</span><span>Quality</span><span>Messages sent</span><span>Last updated</span><span>Actions</span>
</div> </div>
{filtered.map((row) => ( {filtered.map((row) => (
<div className="wa-tpl-row" key={row.id}> <div className="wa-tpl-row" key={row.id}>
<button type="button" className="wa-tpl-name" onClick={() => navigate(`/console/whatsapp/templates/${row.id}`)}>{row.name}</button> <button type="button" className="wa-tpl-name" onClick={() => navigate(`/console/whatsapp/templates/${row.id}`)}>{row.name}</button>
<span className="wa-tpl-number">{(row.phones && row.phones.length ? row.phones : [row.display_phone]).filter(Boolean).join(', ') || 'Shared on the account'}</span>
<span className="wa-tpl-connection"><strong>{row.connection_name || row.account_name}</strong><small>{row.connection_method}</small></span>
<span>{row.category.toLowerCase()}</span> <span>{row.category.toLowerCase()}</span>
<span>{row.language}</span> <span>{row.language}</span>
<span className={`wa-tpl-status is-${row.status.toLowerCase()}`}>{statusLabel(row.status)}</span> <span className={`wa-tpl-status is-${row.status.toLowerCase()}`}>{statusLabel(row.status)}</span>

View file

@ -2014,7 +2014,9 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-tpl-cards strong { display: block; margin-top: 6px; font-size: 26px; color: #14233b; } .wa-tpl-cards strong { display: block; margin-top: 6px; font-size: 26px; color: #14233b; }
.wa-tpl-filters input, .wa-tpl-filters select, .wa-tpl-button input, .wa-tpl-button select, .wa-tpl-samples input { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; background: white; } .wa-tpl-filters input, .wa-tpl-filters select, .wa-tpl-button input, .wa-tpl-button select, .wa-tpl-samples input { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; background: white; }
.wa-tpl-table { margin-top: 14px; overflow-x: auto; } .wa-tpl-table { margin-top: 14px; overflow-x: auto; }
.wa-tpl-row { display: grid; grid-template-columns: 1.4fr .8fr .6fr .8fr .6fr .7fr .8fr 1.3fr; gap: 8px; align-items: center; min-width: 860px; padding: 12px 4px; border-top: 1px solid #edf0f4; font-size: 13px; } .wa-tpl-row { display: grid; grid-template-columns: 1.15fr 1fr 1.15fr .7fr .55fr .7fr .55fr .5fr .7fr 1.15fr; gap: 8px; align-items: center; min-width: 1180px; padding: 12px 4px; border-top: 1px solid #edf0f4; font-size: 13px; }
.wa-tpl-connection { display: grid; gap: 2px; }
.wa-tpl-connection small, .wa-tpl-number { color: #526176; }
.wa-tpl-head { border-top: 0; color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; } .wa-tpl-head { border-top: 0; color: #8a95a7; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.wa-tpl-name, .wa-tpl-actions button, .wa-tpl-format button, .wa-tpl-tabs button { border: 0; background: transparent; color: #1475ff; padding: 0; font-weight: 600; } .wa-tpl-name, .wa-tpl-actions button, .wa-tpl-format button, .wa-tpl-tabs button { border: 0; background: transparent; color: #1475ff; padding: 0; font-weight: 600; }
.wa-tpl-actions { display: flex; gap: 8px; } .wa-tpl-actions { display: flex; gap: 8px; }

View file

@ -1377,8 +1377,11 @@ export type WaManagedTemplate = {
account_id: string account_id: string
account_name: string account_name: string
waba_name: string waba_name: string
connection_name: string
connection_method: string
phone_number_id: string | null phone_number_id: string | null
display_phone: string display_phone: string
phones: string[]
meta_template_id: string | null meta_template_id: string | null
name: string name: string
language: string language: string