347 lines
10 KiB
Python
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
|