first commit
This commit is contained in:
commit
efa5080897
127 changed files with 18610 additions and 0 deletions
171
backend/app/campaign_csv_service.py
Normal file
171
backend/app/campaign_csv_service.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
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]
|
||||
Loading…
Add table
Add a link
Reference in a new issue