template
This commit is contained in:
parent
6b6b929fea
commit
9f03e5df32
9 changed files with 225 additions and 37 deletions
|
|
@ -78,6 +78,7 @@ async def lifespan(_: FastAPI):
|
|||
conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS contact_name VARCHAR(200)"))
|
||||
conn.execute(text("ALTER TABLE whatsapp_meta_connections ADD COLUMN IF NOT EXISTS meta_app_id VARCHAR(64)"))
|
||||
conn.execute(text("ALTER TABLE whatsapp_meta_connections ADD COLUMN IF NOT EXISTS meta_app_secret TEXT"))
|
||||
conn.execute(text("ALTER TABLE whatsapp_broadcast_jobs ADD COLUMN IF NOT EXISTS failures JSON"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_user_id ON whatsapp_messages (user_id)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_phone_number_id ON whatsapp_messages (phone_number_id)"))
|
||||
yield
|
||||
|
|
|
|||
|
|
@ -667,6 +667,7 @@ class WhatsAppBroadcastJob(Base):
|
|||
sent_count: Mapped[int] = mapped_column(default=0)
|
||||
failed_count: Mapped[int] = mapped_column(default=0)
|
||||
next_index: Mapped[int] = mapped_column(default=0)
|
||||
failures: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
last_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from .models import (
|
|||
WhatsAppPhoneAsset,
|
||||
WhatsAppWebhookEvent,
|
||||
)
|
||||
from .whatsapp_broadcast_jobs import create_csv_broadcast, list_broadcast_jobs
|
||||
from .whatsapp_broadcast_jobs import broadcast_failure_csv, create_csv_broadcast, list_broadcast_jobs
|
||||
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
|
||||
from .whatsapp_config import resolved_whatsapp_app_secret
|
||||
from .whatsapp_onboarding import (
|
||||
|
|
@ -369,6 +369,21 @@ def whatsapp_broadcast_jobs(
|
|||
return list_broadcast_jobs(db, user, account_id)
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}/broadcasts/jobs/{job_id}/failures.csv")
|
||||
def whatsapp_broadcast_failures(
|
||||
account_id: str,
|
||||
job_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> Response:
|
||||
content, filename = broadcast_failure_csv(db, user, account_id, job_id)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/broadcasts/csv")
|
||||
async def whatsapp_broadcast_csv(
|
||||
account_id: str,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import csv
|
|||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
|
@ -17,7 +17,7 @@ from sqlalchemy import select
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from .database import SessionLocal
|
||||
from .models import UserAccount, WhatsAppBroadcastJob
|
||||
from .models import UserAccount, WhatsAppBroadcastJob, WhatsAppMessage
|
||||
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
|
||||
from .whatsapp_messages import extract_wa_message_id, record_outbound_message
|
||||
from .whatsapp_onboarding import _messaging_phone, _owned_account
|
||||
|
|
@ -66,7 +66,7 @@ def prepare_phone_number(raw: str, country_code: str) -> tuple[str | None, str |
|
|||
return digits, None
|
||||
|
||||
|
||||
def _parse_csv(raw: bytes, country_code: str) -> tuple[list[str], int]:
|
||||
def _parse_csv(raw: bytes, country_code: str) -> tuple[list[str], list[dict[str, str]]]:
|
||||
try:
|
||||
text = raw.decode("utf-8-sig")
|
||||
except UnicodeDecodeError as exc:
|
||||
|
|
@ -89,18 +89,22 @@ def _parse_csv(raw: bytes, country_code: str) -> tuple[list[str], int]:
|
|||
)
|
||||
ready: list[str] = []
|
||||
seen: set[str] = set()
|
||||
failed = 0
|
||||
failures: list[dict[str, str]] = []
|
||||
for row in filled:
|
||||
number, reason = prepare_phone_number(str(row.get("phone_number") or ""), country_code)
|
||||
original = str(row.get("phone_number") or "").strip()
|
||||
number, reason = prepare_phone_number(original, country_code)
|
||||
if reason or not number:
|
||||
failed += 1
|
||||
failures.append({
|
||||
"phone_number": original,
|
||||
"reason": reason or "This number does not match the selected country code.",
|
||||
})
|
||||
continue
|
||||
if number in seen:
|
||||
failed += 1
|
||||
failures.append({"phone_number": original, "reason": "This number is already in the file."})
|
||||
continue
|
||||
seen.add(number)
|
||||
ready.append(number)
|
||||
return ready, failed
|
||||
return ready, failures
|
||||
|
||||
|
||||
def _serialize_job(job: WhatsAppBroadcastJob) -> dict[str, Any]:
|
||||
|
|
@ -121,6 +125,66 @@ def _serialize_job(job: WhatsAppBroadcastJob) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def _owned_job(db: Session, user: UserAccount, account_id: Any, job_id: Any) -> WhatsAppBroadcastJob:
|
||||
account = _owned_account(db, user, account_id)
|
||||
try:
|
||||
parsed = job_id if isinstance(job_id, UUID) else UUID(str(job_id))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="This broadcast was not found.") from exc
|
||||
job = db.get(WhatsAppBroadcastJob, parsed)
|
||||
if job is None or job.user_id != user.id or job.account_id != account.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="This broadcast was not found.")
|
||||
return job
|
||||
|
||||
|
||||
def _legacy_failures(db: Session, job: WhatsAppBroadcastJob) -> list[dict[str, str]]:
|
||||
"""Older broadcasts kept only a count. Recover numbers Meta actually rejected."""
|
||||
recipients = [str(item) for item in (job.recipients or [])]
|
||||
if not recipients or job.created_at is None:
|
||||
return []
|
||||
end = (job.completed_at or datetime.now(timezone.utc)) + timedelta(minutes=2)
|
||||
sent = set(
|
||||
db.scalars(
|
||||
select(WhatsAppMessage.to_number).where(
|
||||
WhatsAppMessage.user_id == job.user_id,
|
||||
WhatsAppMessage.body == f"Broadcast: {job.template_name}",
|
||||
WhatsAppMessage.created_at >= job.created_at,
|
||||
WhatsAppMessage.created_at <= end,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
missing = [number for number in recipients if number not in sent]
|
||||
parse_failed = max(job.total - len(recipients), 0)
|
||||
send_failed = max(job.failed_count - parse_failed, 0)
|
||||
unsent = max(len(recipients) - job.next_index, 0) if job.status == "failed" else 0
|
||||
if not missing or len(missing) > send_failed + unsent:
|
||||
return []
|
||||
reason = (job.last_error or "Meta did not accept this number.")[:500]
|
||||
return [{"phone_number": number, "reason": reason} for number in missing]
|
||||
|
||||
|
||||
def broadcast_failure_csv(db: Session, user: UserAccount, account_id: Any, job_id: Any) -> tuple[str, str]:
|
||||
job = _owned_job(db, user, account_id, job_id)
|
||||
rows = job.failures if isinstance(job.failures, list) else _legacy_failures(db, job)
|
||||
cleaned = [
|
||||
{"phone_number": str(item.get("phone_number") or ""), "reason": str(item.get("reason") or "")}
|
||||
for item in rows
|
||||
if isinstance(item, dict) and str(item.get("phone_number") or "").strip()
|
||||
]
|
||||
if not cleaned:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Failed numbers were not saved for this broadcast. New broadcasts keep each one.",
|
||||
)
|
||||
buffer = io.StringIO()
|
||||
writer = csv.writer(buffer)
|
||||
writer.writerow(["phone_number", "reason"])
|
||||
for item in cleaned:
|
||||
writer.writerow([item["phone_number"], item["reason"]])
|
||||
stem = re.sub(r"[^A-Za-z0-9._-]+", "-", Path(job.filename).stem)[:80] or "broadcast"
|
||||
return buffer.getvalue(), f"{stem}-failed.csv"
|
||||
|
||||
|
||||
def list_broadcast_jobs(db: Session, user: UserAccount, account_id: Any) -> list[dict[str, Any]]:
|
||||
account = _owned_account(db, user, account_id)
|
||||
rows = db.scalars(
|
||||
|
|
@ -173,7 +237,7 @@ async def create_csv_broadcast(
|
|||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Add the sample file on the template page before this broadcast can attach it.",
|
||||
)
|
||||
ready, failed = _parse_csv(raw, code)
|
||||
ready, failures = _parse_csv(raw, code)
|
||||
safe_name = Path(filename or "broadcast.csv").name[:200] or "broadcast.csv"
|
||||
job = WhatsAppBroadcastJob(
|
||||
user_id=user.id,
|
||||
|
|
@ -187,10 +251,11 @@ async def create_csv_broadcast(
|
|||
body_parameters=[str(item).strip() for item in body_parameters if str(item).strip()],
|
||||
recipients=ready,
|
||||
status="processing",
|
||||
total=len(ready) + failed,
|
||||
total=len(ready) + len(failures),
|
||||
sent_count=0,
|
||||
failed_count=failed,
|
||||
failed_count=len(failures),
|
||||
next_index=0,
|
||||
failures=failures,
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
|
|
@ -345,6 +410,8 @@ async def run_broadcast_job(job_id: UUID) -> None:
|
|||
else:
|
||||
job.failed_count += 1
|
||||
job.last_error = (error or "Meta did not accept this number.")[:500]
|
||||
if isinstance(job.failures, list):
|
||||
job.failures = [*job.failures, {"phone_number": number, "reason": job.last_error}]
|
||||
db.commit()
|
||||
if len(batch) == BATCH_SIZE:
|
||||
await asyncio.sleep(COOLDOWN_SECONDS)
|
||||
|
|
@ -364,5 +431,14 @@ def _mark_failed(job_id: UUID, message: str) -> None:
|
|||
return
|
||||
job.status = "failed"
|
||||
job.last_error = message[:500]
|
||||
if isinstance(job.failures, list):
|
||||
recipients = [str(item) for item in (job.recipients or [])]
|
||||
pending = [
|
||||
{"phone_number": number, "reason": "The broadcast stopped before this number was sent."}
|
||||
for number in recipients[job.next_index:]
|
||||
]
|
||||
if pending:
|
||||
job.failures = [*job.failures, *pending]
|
||||
job.failed_count += len(pending)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue