This commit is contained in:
debu-2 2026-10-15 03:28:51 +05:30
parent 6b6b929fea
commit 9f03e5df32
9 changed files with 225 additions and 37 deletions

View file

@ -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()