171 lines
5.5 KiB
Python
171 lines
5.5 KiB
Python
import csv
|
|
import io
|
|
import re
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .config import settings
|
|
from .models import Campaign, CampaignCsvUpload, TemplateConfig
|
|
from .template_message_validator import PLACEHOLDER_PATTERN, message_matches_template
|
|
|
|
PHONE_RE = re.compile(r"^\d{10,15}$")
|
|
|
|
|
|
@dataclass
|
|
class CsvValidationResult:
|
|
total_rows: int
|
|
headers: list[str]
|
|
|
|
|
|
def template_variable_count(template_text: str) -> int:
|
|
return len(PLACEHOLDER_PATTERN.split(template_text.strip())) - 1
|
|
|
|
|
|
def build_message_from_row(template_text: str, variable_values: list[str]) -> str:
|
|
parts = PLACEHOLDER_PATTERN.split(template_text.strip())
|
|
message = ""
|
|
for index, part in enumerate(parts):
|
|
message += part
|
|
if index < len(parts) - 1:
|
|
message += variable_values[index] if index < len(variable_values) else ""
|
|
return message
|
|
|
|
|
|
def normalize_phone(value: str) -> str:
|
|
return re.sub(r"\D", "", value.strip())
|
|
|
|
|
|
def validate_campaign_csv(
|
|
*,
|
|
file_bytes: bytes,
|
|
template: TemplateConfig,
|
|
number_column: str,
|
|
variable_columns: list[str],
|
|
max_rows: int = 50_000,
|
|
) -> tuple[CsvValidationResult, list[str]]:
|
|
errors: list[str] = []
|
|
expected_vars = template_variable_count(template.template_text)
|
|
if len(variable_columns) != expected_vars:
|
|
errors.append(
|
|
f"Campaign expects {expected_vars} template variable column(s); "
|
|
f"{len(variable_columns)} configured"
|
|
)
|
|
return CsvValidationResult(total_rows=0, headers=[]), errors
|
|
|
|
try:
|
|
text = file_bytes.decode("utf-8-sig")
|
|
except UnicodeDecodeError:
|
|
return CsvValidationResult(total_rows=0, headers=[]), ["CSV must be UTF-8 encoded"]
|
|
|
|
reader = csv.DictReader(io.StringIO(text))
|
|
if not reader.fieldnames:
|
|
return CsvValidationResult(total_rows=0, headers=[]), ["CSV must include a header row"]
|
|
|
|
headers = [header.strip() for header in reader.fieldnames if header and header.strip()]
|
|
required = [number_column, *variable_columns]
|
|
missing = [column for column in required if column not in headers]
|
|
if missing:
|
|
return CsvValidationResult(total_rows=0, headers=headers), [
|
|
f"Missing required column(s): {', '.join(missing)}"
|
|
]
|
|
|
|
total_rows = 0
|
|
for row_number, row in enumerate(reader, start=2):
|
|
total_rows += 1
|
|
if total_rows > max_rows:
|
|
errors.append(f"CSV exceeds maximum of {max_rows} rows")
|
|
break
|
|
|
|
phone = normalize_phone(row.get(number_column, "") or "")
|
|
if not phone:
|
|
errors.append(f"Row {row_number}: empty number in column '{number_column}'")
|
|
continue
|
|
if not PHONE_RE.fullmatch(phone):
|
|
errors.append(f"Row {row_number}: invalid number '{row.get(number_column, '')}'")
|
|
continue
|
|
|
|
variable_values = [(row.get(column, "") or "").strip() for column in variable_columns]
|
|
if any(not value for value in variable_values):
|
|
errors.append(f"Row {row_number}: all template variable columns must be filled")
|
|
continue
|
|
|
|
message = build_message_from_row(template.template_text, variable_values)
|
|
if not message_matches_template(template.template_text, message):
|
|
errors.append(f"Row {row_number}: message does not match the approved template")
|
|
|
|
if len(errors) >= 25:
|
|
errors.append("Too many validation errors; fix the CSV and re-upload")
|
|
break
|
|
|
|
if total_rows == 0 and not errors:
|
|
errors.append("CSV must contain at least one data row")
|
|
|
|
return CsvValidationResult(total_rows=total_rows, headers=headers), errors
|
|
|
|
|
|
def save_campaign_csv(
|
|
db: Session,
|
|
campaign: Campaign,
|
|
*,
|
|
original_filename: str,
|
|
file_bytes: bytes,
|
|
auto_run: bool,
|
|
) -> CampaignCsvUpload:
|
|
result, errors = validate_campaign_csv(
|
|
file_bytes=file_bytes,
|
|
template=campaign.template,
|
|
number_column=campaign.number_column,
|
|
variable_columns=campaign.variable_columns,
|
|
)
|
|
if errors:
|
|
raise ValueError("; ".join(errors))
|
|
|
|
upload_id = uuid.uuid4()
|
|
campaign_dir = Path(settings.upload_dir) / "campaigns" / str(campaign.id)
|
|
campaign_dir.mkdir(parents=True, exist_ok=True)
|
|
stored_path = campaign_dir / f"{upload_id}.csv"
|
|
stored_path.write_bytes(file_bytes)
|
|
|
|
upload = CampaignCsvUpload(
|
|
id=upload_id,
|
|
campaign_id=campaign.id,
|
|
original_filename=original_filename,
|
|
stored_path=str(stored_path),
|
|
total_rows=result.total_rows,
|
|
status="validated",
|
|
)
|
|
db.add(upload)
|
|
db.commit()
|
|
db.refresh(upload)
|
|
|
|
if auto_run:
|
|
stub_run_csv_upload(db, upload)
|
|
|
|
return upload
|
|
|
|
|
|
def stub_run_csv_upload(db: Session, upload: CampaignCsvUpload) -> CampaignCsvUpload:
|
|
"""Placeholder until Redis/worker batch jobs are wired (see docs/BATCH_JOBS_TODO.md)."""
|
|
now = datetime.now(timezone.utc)
|
|
upload.status = "running"
|
|
upload.run_started_at = now
|
|
db.commit()
|
|
|
|
upload.status = "completed"
|
|
upload.run_completed_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
db.refresh(upload)
|
|
return upload
|
|
|
|
|
|
def stub_run_campaign_automation(db: Session, campaign: Campaign) -> list[CampaignCsvUpload]:
|
|
pending = [
|
|
upload
|
|
for upload in campaign.csv_uploads
|
|
if upload.status == "validated"
|
|
]
|
|
return [stub_run_csv_upload(db, upload) for upload in pending]
|