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.", }