first commit

This commit is contained in:
debu-2 2026-10-02 11:22:40 +05:30
commit efa5080897
127 changed files with 18610 additions and 0 deletions

View 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]