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