sendio/backend/app/api/campaigns.py
2026-01-13 05:17:57 +02:00

347 lines
10 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List
from datetime import datetime
from app.db.base import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.campaign import Campaign, CampaignRecipient, CampaignStatus, RecipientStatus
from app.models.template import Template
from app.models.list import List as ContactList, ListMember
from app.models.contact import Contact, DNDList
from app.models.job import Job
from app.schemas.campaign import (
CampaignCreate, CampaignUpdate, CampaignResponse,
RecipientResponse, CampaignPreview
)
router = APIRouter()
@router.post("", response_model=CampaignResponse, status_code=status.HTTP_201_CREATED)
def create_campaign(
campaign: CampaignCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Verify template exists
template = db.query(Template).filter(
Template.id == campaign.template_id,
Template.user_id == current_user.id
).first()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
# Verify list exists
contact_list = db.query(ContactList).filter(
ContactList.id == campaign.list_id,
ContactList.user_id == current_user.id
).first()
if not contact_list:
raise HTTPException(status_code=404, detail="List not found")
# Create campaign
db_campaign = Campaign(
user_id=current_user.id,
name=campaign.name,
template_id=campaign.template_id,
list_id=campaign.list_id,
status=CampaignStatus.DRAFT,
scheduled_at=campaign.scheduled_at
)
db.add(db_campaign)
db.commit()
db.refresh(db_campaign)
# Create recipients
members = db.query(ListMember).filter(ListMember.list_id == campaign.list_id).all()
for member in members:
recipient = CampaignRecipient(
campaign_id=db_campaign.id,
contact_id=member.contact_id,
status=RecipientStatus.PENDING
)
db.add(recipient)
db.commit()
return db_campaign
@router.get("", response_model=List[CampaignResponse])
def list_campaigns(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
return db.query(Campaign).filter(
Campaign.user_id == current_user.id
).order_by(Campaign.created_at.desc()).offset(skip).limit(limit).all()
@router.get("/{campaign_id}", response_model=CampaignResponse)
def get_campaign(
campaign_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
campaign = db.query(Campaign).filter(
Campaign.id == campaign_id,
Campaign.user_id == current_user.id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
return campaign
@router.put("/{campaign_id}", response_model=CampaignResponse)
def update_campaign(
campaign_id: int,
campaign_update: CampaignUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
campaign = db.query(Campaign).filter(
Campaign.id == campaign_id,
Campaign.user_id == current_user.id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
update_data = campaign_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(campaign, field, value)
db.commit()
db.refresh(campaign)
return campaign
@router.delete("/{campaign_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_campaign(
campaign_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
campaign = db.query(Campaign).filter(
Campaign.id == campaign_id,
Campaign.user_id == current_user.id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
db.delete(campaign)
db.commit()
return None
@router.get("/{campaign_id}/preview", response_model=CampaignPreview)
def preview_campaign(
campaign_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Preview campaign recipients and compliance stats"""
campaign = db.query(Campaign).filter(
Campaign.id == campaign_id,
Campaign.user_id == current_user.id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
# Get all recipients
recipients = db.query(CampaignRecipient).filter(
CampaignRecipient.campaign_id == campaign_id
).all()
total_count = len(recipients)
opted_in_count = 0
dnd_count = 0
sample_contacts = []
for recipient in recipients[:5]: # Sample first 5
contact = db.query(Contact).filter(Contact.id == recipient.contact_id).first()
if not contact:
continue
# Check if opted in
if contact.opted_in:
opted_in_count += 1
# Check DND
dnd_entry = db.query(DNDList).filter(
DNDList.user_id == current_user.id,
DNDList.phone_e164 == contact.phone_e164
).first()
if dnd_entry:
dnd_count += 1
sample_contacts.append({
"phone": contact.phone_e164,
"name": f"{contact.first_name or ''} {contact.last_name or ''}".strip(),
"opted_in": contact.opted_in,
"in_dnd": dnd_entry is not None
})
# Count all opted in
all_contact_ids = [r.contact_id for r in recipients]
opted_in_count = db.query(Contact).filter(
Contact.id.in_(all_contact_ids),
Contact.opted_in == True
).count()
# Count all DND
all_phones = [
c.phone_e164 for c in db.query(Contact.phone_e164).filter(
Contact.id.in_(all_contact_ids)
).all()
]
dnd_count = db.query(DNDList).filter(
DNDList.user_id == current_user.id,
DNDList.phone_e164.in_(all_phones)
).count()
eligible_count = total_count - dnd_count
return CampaignPreview(
total_recipients=total_count,
opted_in_count=opted_in_count,
dnd_count=dnd_count,
eligible_count=eligible_count,
sample_contacts=sample_contacts
)
@router.post("/{campaign_id}/send")
def send_campaign(
campaign_id: int,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Start sending campaign (creates a job)"""
campaign = db.query(Campaign).filter(
Campaign.id == campaign_id,
Campaign.user_id == current_user.id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
if campaign.status not in [CampaignStatus.DRAFT, CampaignStatus.SCHEDULED]:
raise HTTPException(status_code=400, detail="Campaign already sent or in progress")
# Update status
campaign.status = CampaignStatus.SENDING
db.commit()
# Create job for background processing
job = Job(
user_id=current_user.id,
type="send_campaign",
payload_json={"campaign_id": campaign_id},
status="pending",
attempts=0
)
db.add(job)
db.commit()
return {
"status": "started",
"campaign_id": campaign_id,
"job_id": job.id
}
@router.post("/{campaign_id}/reset")
def reset_campaign(
campaign_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Reset campaign to draft and all recipients to pending"""
campaign = db.query(Campaign).filter(
Campaign.id == campaign_id,
Campaign.user_id == current_user.id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
# Reset campaign status
campaign.status = CampaignStatus.DRAFT
# Reset all recipients to pending
db.query(CampaignRecipient).filter(
CampaignRecipient.campaign_id == campaign_id
).update({
"status": RecipientStatus.PENDING,
"provider_message_id": None,
"last_error": None
})
# Delete any pending jobs for this campaign
from sqlalchemy import cast, String
db.query(Job).filter(
Job.type == "send_campaign",
cast(Job.payload_json["campaign_id"], String) == str(campaign_id),
Job.status == "pending"
).delete(synchronize_session=False)
db.commit()
return {
"status": "reset",
"campaign_id": campaign_id,
"message": "Campaign reset to draft. All recipients set to pending."
}
@router.get("/{campaign_id}/recipients", response_model=List[RecipientResponse])
def get_campaign_recipients(
campaign_id: int,
status: str = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get campaign recipients with optional status filter"""
campaign = db.query(Campaign).filter(
Campaign.id == campaign_id,
Campaign.user_id == current_user.id
).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
query = db.query(CampaignRecipient).filter(
CampaignRecipient.campaign_id == campaign_id
)
if status:
query = query.filter(CampaignRecipient.status == status)
recipients = query.offset(skip).limit(limit).all()
# Enrich with contact data
result = []
for recipient in recipients:
contact = db.query(Contact).filter(Contact.id == recipient.contact_id).first()
recipient_dict = {
"id": recipient.id,
"campaign_id": recipient.campaign_id,
"contact_id": recipient.contact_id,
"status": recipient.status,
"provider_message_id": recipient.provider_message_id,
"last_error": recipient.last_error,
"updated_at": recipient.updated_at,
"contact_phone": contact.phone_e164 if contact else None,
"contact_name": f"{contact.first_name or ''} {contact.last_name or ''}".strip() if contact else None
}
result.append(recipient_dict)
return result