urmi-ai/backend/app/routes_campaigns.py
2026-10-02 11:22:40 +05:30

307 lines
10 KiB
Python

from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from .auth import current_user
from .campaign_csv_service import (
save_campaign_csv,
stub_run_campaign_automation,
stub_run_csv_upload,
template_variable_count,
)
from .config import settings
from .database import get_db
from .models import Campaign, CampaignCsvUpload, SenderConfig, TemplateConfig, UserAccount
router = APIRouter(prefix="/api/v1/campaigns", tags=["campaigns"])
RUN_MODES = {"manual", "on_upload", "scheduled"}
class CreateCampaignRequest(BaseModel):
name: str = Field(min_length=3, max_length=80)
sender_config_id: str
template_id: str
number_column: str = Field(min_length=1, max_length=80)
variable_columns: list[str] = Field(min_length=0, max_length=10)
run_mode: str
scheduled_at: datetime | None = None
def serialize_csv_upload(upload: CampaignCsvUpload) -> dict:
return {
"id": str(upload.id),
"original_filename": upload.original_filename,
"total_rows": upload.total_rows,
"status": upload.status,
"run_started_at": upload.run_started_at,
"run_completed_at": upload.run_completed_at,
"created_at": upload.created_at,
}
def serialize_campaign(campaign: Campaign) -> dict:
return {
"id": str(campaign.id),
"name": campaign.name,
"sender_config_id": str(campaign.sender_config_id),
"sender_id": campaign.sender_config.sender_id,
"sender_type": campaign.sender_config.sender_type,
"template_id": str(campaign.template_id),
"template_name": campaign.template.template_name,
"template_text": campaign.template.template_text,
"number_column": campaign.number_column,
"variable_columns": campaign.variable_columns,
"run_mode": campaign.run_mode,
"scheduled_at": campaign.scheduled_at,
"status": campaign.status,
"created_at": campaign.created_at,
"updated_at": campaign.updated_at,
"csv_upload_count": len(campaign.csv_uploads),
"csv_uploads": [serialize_csv_upload(upload) for upload in campaign.csv_uploads],
}
def get_owned_campaign(db: Session, user: UserAccount, campaign_id: UUID) -> Campaign:
campaign = db.scalar(
select(Campaign)
.options(
joinedload(Campaign.sender_config),
joinedload(Campaign.template),
joinedload(Campaign.csv_uploads),
)
.where(Campaign.id == campaign_id, Campaign.user_id == user.id)
)
if not campaign:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found")
return campaign
def validate_campaign_links(
db: Session,
user: UserAccount,
*,
sender_config_id: str,
template_id: str,
variable_columns: list[str],
) -> tuple[SenderConfig, TemplateConfig]:
sender = db.scalar(
select(SenderConfig).where(
SenderConfig.id == sender_config_id,
SenderConfig.user_id == user.id,
SenderConfig.status == "approved",
)
)
if not sender:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Select an approved sender ID",
)
template = db.scalar(
select(TemplateConfig).where(
TemplateConfig.id == template_id,
TemplateConfig.user_id == user.id,
TemplateConfig.sender_config_id == sender.id,
TemplateConfig.status == "approved",
)
)
if not template:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Select an approved template linked to the sender",
)
expected = template_variable_count(template.template_text)
if len(variable_columns) != expected:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Template requires {expected} variable column mapping(s)",
)
cleaned_columns = [column.strip() for column in variable_columns]
if any(not column for column in cleaned_columns):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Variable column names cannot be empty",
)
return sender, template
@router.get("")
def list_campaigns(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
campaigns = db.scalars(
select(Campaign)
.options(
joinedload(Campaign.sender_config),
joinedload(Campaign.template),
joinedload(Campaign.csv_uploads),
)
.where(Campaign.user_id == user.id)
.order_by(Campaign.created_at.desc())
).unique().all()
return [serialize_campaign(campaign) for campaign in campaigns]
@router.post("", status_code=status.HTTP_201_CREATED)
def create_campaign(
payload: CreateCampaignRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
if payload.run_mode not in RUN_MODES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Run mode must be manual, on_upload, or scheduled",
)
if payload.run_mode == "scheduled" and payload.scheduled_at is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Scheduled campaigns require a scheduled_at timestamp",
)
number_column = payload.number_column.strip()
if not number_column:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Number column is required",
)
sender, template = validate_campaign_links(
db,
user,
sender_config_id=payload.sender_config_id,
template_id=payload.template_id,
variable_columns=payload.variable_columns,
)
campaign = Campaign(
user_id=user.id,
name=payload.name.strip(),
sender_config_id=sender.id,
template_id=template.id,
number_column=number_column,
variable_columns=[column.strip() for column in payload.variable_columns],
run_mode=payload.run_mode,
scheduled_at=payload.scheduled_at,
status="active",
)
db.add(campaign)
db.commit()
db.refresh(campaign)
campaign.sender_config = sender
campaign.template = template
campaign.csv_uploads = []
return serialize_campaign(campaign)
@router.get("/{campaign_id}")
def get_campaign(
campaign_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
campaign = get_owned_campaign(db, user, campaign_id)
return serialize_campaign(campaign)
@router.get("/{campaign_id}/csv")
def list_campaign_csv_uploads(
campaign_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
campaign = get_owned_campaign(db, user, campaign_id)
uploads = sorted(campaign.csv_uploads, key=lambda item: item.created_at, reverse=True)
return [serialize_csv_upload(upload) for upload in uploads]
@router.post("/{campaign_id}/csv", status_code=status.HTTP_201_CREATED)
async def upload_campaign_csv(
campaign_id: UUID,
file: UploadFile = File(...),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
campaign = get_owned_campaign(db, user, campaign_id)
if not file.filename or not file.filename.lower().endswith(".csv"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Upload a .csv file",
)
file_bytes = await file.read()
if len(file_bytes) > settings.max_upload_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="CSV file is too large",
)
if not file_bytes.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="CSV file is empty",
)
try:
upload = save_campaign_csv(
db,
campaign,
original_filename=file.filename,
file_bytes=file_bytes,
auto_run=campaign.run_mode == "on_upload",
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return serialize_csv_upload(upload)
@router.post("/{campaign_id}/run")
def run_campaign_automation(
campaign_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
campaign = get_owned_campaign(db, user, campaign_id)
uploads = stub_run_campaign_automation(db, campaign)
return {
"campaign_id": str(campaign.id),
"processed_uploads": len(uploads),
"uploads": [serialize_csv_upload(upload) for upload in uploads],
"note": "Send functionality is not enabled yet; runs are recorded as completed stubs.",
}
@router.post("/{campaign_id}/csv/{upload_id}/run")
def run_campaign_csv(
campaign_id: UUID,
upload_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
campaign = get_owned_campaign(db, user, campaign_id)
upload = next((item for item in campaign.csv_uploads if item.id == upload_id), None)
if not upload:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="CSV upload not found")
if upload.status not in {"validated", "completed"}:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Cannot run CSV in status '{upload.status}'",
)
upload = stub_run_csv_upload(db, upload)
return {
**serialize_csv_upload(upload),
"note": "Send functionality is not enabled yet; run recorded as a completed stub.",
}