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]