585 lines
21 KiB
Python
585 lines
21 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
|
from sqlalchemy.orm import Session, selectinload
|
|
from typing import Optional, List, Union
|
|
from datetime import datetime
|
|
from pydantic import BaseModel, EmailStr, field_validator
|
|
|
|
from ..config.database import get_db
|
|
from ..middleware.auth import get_current_user, authorize_roles
|
|
from ..models.user import User
|
|
from ..models.email_campaign import (
|
|
Campaign, CampaignStatus, CampaignType,
|
|
CampaignSegment, EmailTemplate, CampaignEmail, EmailStatus,
|
|
DripSequence, DripSequenceStep, Unsubscribe
|
|
)
|
|
from ..services.email_campaign_service import email_campaign_service
|
|
|
|
router = APIRouter(prefix="/email-campaigns", tags=["Email Campaigns"])
|
|
|
|
# Pydantic Models
|
|
class CampaignCreate(BaseModel):
|
|
name: str
|
|
subject: str
|
|
html_content: str
|
|
text_content: Optional[str] = None
|
|
campaign_type: str = "newsletter"
|
|
segment_id: Optional[Union[int, str]] = None
|
|
scheduled_at: Optional[datetime] = None
|
|
template_id: Optional[Union[int, str]] = None
|
|
from_name: Optional[str] = None
|
|
from_email: Optional[str] = None
|
|
reply_to_email: Optional[str] = None
|
|
track_opens: bool = True
|
|
track_clicks: bool = True
|
|
|
|
@field_validator('segment_id', 'template_id', mode='before')
|
|
@classmethod
|
|
def parse_int_or_none(cls, v):
|
|
if v is None or v == '' or v == 'undefined' or (isinstance(v, str) and v.strip() == ''):
|
|
return None
|
|
if isinstance(v, str):
|
|
try:
|
|
return int(v)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
if isinstance(v, int):
|
|
return v
|
|
return None
|
|
|
|
class CampaignUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
subject: Optional[str] = None
|
|
html_content: Optional[str] = None
|
|
text_content: Optional[str] = None
|
|
segment_id: Optional[Union[int, str]] = None
|
|
scheduled_at: Optional[datetime] = None
|
|
status: Optional[str] = None
|
|
|
|
@field_validator('segment_id', mode='before')
|
|
@classmethod
|
|
def parse_int_or_none(cls, v):
|
|
if v is None or v == '' or v == 'undefined' or (isinstance(v, str) and v.strip() == ''):
|
|
return None
|
|
if isinstance(v, str):
|
|
try:
|
|
return int(v)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
if isinstance(v, int):
|
|
return v
|
|
return None
|
|
|
|
class SegmentCreate(BaseModel):
|
|
name: str
|
|
description: Optional[str] = None
|
|
criteria: dict
|
|
|
|
class TemplateCreate(BaseModel):
|
|
name: str
|
|
subject: str
|
|
html_content: str
|
|
text_content: Optional[str] = None
|
|
category: Optional[str] = None
|
|
variables: Optional[List[str]] = None
|
|
|
|
class DripSequenceCreate(BaseModel):
|
|
name: str
|
|
description: Optional[str] = None
|
|
trigger_event: Optional[str] = None
|
|
|
|
class DripStepCreate(BaseModel):
|
|
subject: str
|
|
html_content: str
|
|
text_content: Optional[str] = None
|
|
delay_days: int = 0
|
|
delay_hours: int = 0
|
|
template_id: Optional[Union[int, str]] = None
|
|
|
|
@field_validator('template_id', mode='before')
|
|
@classmethod
|
|
def parse_int_or_none(cls, v):
|
|
if v is None or v == '' or v == 'undefined' or (isinstance(v, str) and v.strip() == ''):
|
|
return None
|
|
if isinstance(v, str):
|
|
try:
|
|
return int(v)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
if isinstance(v, int):
|
|
return v
|
|
return None
|
|
|
|
# Campaign Routes
|
|
@router.get("")
|
|
async def get_campaigns(
|
|
status_filter: Optional[str] = Query(None, alias='status'),
|
|
campaign_type: Optional[str] = Query(None),
|
|
limit: int = Query(50, ge=1, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
current_user: User = Depends(authorize_roles("admin")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get all email campaigns"""
|
|
query = db.query(Campaign)
|
|
|
|
if status_filter:
|
|
try:
|
|
status_enum = CampaignStatus(status_filter)
|
|
query = query.filter(Campaign.status == status_enum)
|
|
except ValueError:
|
|
pass
|
|
|
|
if campaign_type:
|
|
try:
|
|
type_enum = CampaignType(campaign_type)
|
|
query = query.filter(Campaign.campaign_type == type_enum)
|
|
except ValueError:
|
|
pass
|
|
|
|
campaigns = query.order_by(Campaign.created_at.desc()).offset(offset).limit(limit).all()
|
|
|
|
return [{
|
|
"id": c.id,
|
|
"name": c.name,
|
|
"subject": c.subject,
|
|
"campaign_type": c.campaign_type.value,
|
|
"status": c.status.value,
|
|
"total_recipients": c.total_recipients,
|
|
"total_sent": c.total_sent,
|
|
"total_opened": c.total_opened,
|
|
"total_clicked": c.total_clicked,
|
|
"open_rate": float(c.open_rate) if c.open_rate else None,
|
|
"click_rate": float(c.click_rate) if c.click_rate else None,
|
|
"scheduled_at": c.scheduled_at.isoformat() if c.scheduled_at else None,
|
|
"sent_at": c.sent_at.isoformat() if c.sent_at else None,
|
|
"created_at": c.created_at.isoformat() if c.created_at else None
|
|
} for c in campaigns]
|
|
|
|
# Segment Routes (must be before /{campaign_id} to avoid route conflicts)
|
|
@router.get("/segments")
|
|
async def get_segments(
|
|
current_user: User = Depends(authorize_roles("admin")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get all campaign segments"""
|
|
try:
|
|
segments = db.query(CampaignSegment).filter(CampaignSegment.is_active == True).all()
|
|
return [{
|
|
"id": s.id,
|
|
"name": s.name,
|
|
"description": s.description,
|
|
"criteria": s.criteria,
|
|
"estimated_count": s.estimated_count,
|
|
"created_at": s.created_at.isoformat() if s.created_at else None
|
|
} for s in segments]
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
from ..config.logging_config import get_logger
|
|
logger = get_logger(__name__)
|
|
logger.error(f"Error fetching segments: {str(e)}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to fetch segments: {str(e)}")
|
|
|
|
@router.post("/segments")
|
|
async def create_segment(
|
|
data: SegmentCreate,
|
|
current_user: User = Depends(authorize_roles("admin")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create a new campaign segment"""
|
|
try:
|
|
segment = email_campaign_service.create_segment(
|
|
db=db,
|
|
name=data.name,
|
|
criteria=data.criteria,
|
|
description=data.description,
|
|
created_by=current_user.id
|
|
)
|
|
return {"status": "success", "segment_id": segment.id, "estimated_count": segment.estimated_count}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
from ..config.logging_config import get_logger
|
|
logger = get_logger(__name__)
|
|
logger.error(f"Error creating segment: {str(e)}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to create segment: {str(e)}")
|
|
|
|
# Template Routes (must be before /{campaign_id} to avoid route conflicts)
|
|
@router.get("/templates")
|
|
async def get_templates(
|
|
category: Optional[str] = Query(None, description="Filter by template category"),
|
|
current_user: User = Depends(authorize_roles("admin")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get all email templates"""
|
|
try:
|
|
query = db.query(EmailTemplate).filter(EmailTemplate.is_active == True)
|
|
if category:
|
|
query = query.filter(EmailTemplate.category == category)
|
|
|
|
templates = query.all()
|
|
result = [{
|
|
"id": t.id,
|
|
"name": t.name,
|
|
"subject": t.subject,
|
|
"category": t.category,
|
|
"variables": t.variables,
|
|
"created_at": t.created_at.isoformat() if t.created_at else None
|
|
} for t in templates]
|
|
return result
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
from ..config.logging_config import get_logger
|
|
logger = get_logger(__name__)
|
|
logger.error(f"Error fetching templates: {str(e)}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to fetch templates: {str(e)}")
|
|
|
|
@router.post("/templates")
|
|
async def create_template(
|
|
data: TemplateCreate,
|
|
current_user: User = Depends(authorize_roles("admin")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create a new email template"""
|
|
try:
|
|
template = EmailTemplate(
|
|
name=data.name,
|
|
subject=data.subject,
|
|
html_content=data.html_content,
|
|
text_content=data.text_content,
|
|
category=data.category,
|
|
variables=data.variables,
|
|
created_by=current_user.id
|
|
)
|
|
db.add(template)
|
|
db.commit()
|
|
db.refresh(template)
|
|
return {"status": "success", "template_id": template.id}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
from ..config.logging_config import get_logger
|
|
logger = get_logger(__name__)
|
|
logger.error(f"Error creating template: {str(e)}", exc_info=True)
|
|
db.rollback()
|
|
raise HTTPException(status_code=500, detail=f"Failed to create template: {str(e)}")
|
|
|
|
# Drip Sequence Routes (must be before /{campaign_id} to avoid route conflicts)
|
|
@router.get("/drip-sequences")
|
|
async def get_drip_sequences(
|
|
current_user: User = Depends(authorize_roles("admin")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get all drip sequences"""
|
|
try:
|
|
# Use eager loading to avoid lazy loading issues
|
|
sequences = db.query(DripSequence).options(
|
|
selectinload(DripSequence.steps)
|
|
).filter(DripSequence.is_active == True).all()
|
|
|
|
return [{
|
|
"id": s.id,
|
|
"name": s.name,
|
|
"description": s.description,
|
|
"trigger_event": s.trigger_event,
|
|
"step_count": len(s.steps) if s.steps else 0,
|
|
"created_at": s.created_at.isoformat() if s.created_at else None
|
|
} for s in sequences]
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
from ..config.logging_config import get_logger
|
|
logger = get_logger(__name__)
|
|
logger.error(f"Error fetching drip sequences: {str(e)}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to fetch drip sequences: {str(e)}")
|
|
|
|
@router.post("/drip-sequences")
|
|
async def create_drip_sequence(
|
|
data: DripSequenceCreate,
|
|
current_user: User = Depends(authorize_roles("admin")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create a new drip sequence"""
|
|
try:
|
|
sequence = email_campaign_service.create_drip_sequence(
|
|
db=db,
|
|
name=data.name,
|
|
description=data.description,
|
|
trigger_event=data.trigger_event,
|
|
created_by=current_user.id
|
|
)
|
|
return {"status": "success", "sequence_id": sequence.id}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
from ..config.logging_config import get_logger
|
|
logger = get_logger(__name__)
|
|
logger.error(f"Error creating drip sequence: {str(e)}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to create drip sequence: {str(e)}")
|
|
|
|
@router.post("/drip-sequences/{sequence_id}/steps")
|
|
async def add_drip_step(
|
|
sequence_id: int,
|
|
data: DripStepCreate,
|
|
current_user: User = Depends(authorize_roles("admin")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Add a step to a drip sequence"""
|
|
try:
|
|
# Ensure template_id is integer or None
|
|
template_id = int(data.template_id) if data.template_id is not None else None
|
|
|
|
step = email_campaign_service.add_drip_step(
|
|
db=db,
|
|
sequence_id=sequence_id,
|
|
subject=data.subject,
|
|
html_content=data.html_content,
|
|
text_content=data.text_content,
|
|
delay_days=data.delay_days,
|
|
delay_hours=data.delay_hours,
|
|
template_id=template_id
|
|
)
|
|
return {"status": "success", "step_id": step.id}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
from ..config.logging_config import get_logger
|
|
logger = get_logger(__name__)
|
|
logger.error(f"Error adding drip step: {str(e)}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to add drip step: {str(e)}")
|
|
|
|
@router.get("/{campaign_id}")
|
|
async def get_campaign(
|
|
campaign_id: int,
|
|
current_user: User = Depends(authorize_roles("admin")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get a specific campaign"""
|
|
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
|
if not campaign:
|
|
raise HTTPException(status_code=404, detail="Campaign not found")
|
|
|
|
return {
|
|
"id": campaign.id,
|
|
"name": campaign.name,
|
|
"subject": campaign.subject,
|
|
"html_content": campaign.html_content,
|
|
"text_content": campaign.text_content,
|
|
"campaign_type": campaign.campaign_type.value,
|
|
"status": campaign.status.value,
|
|
"segment_id": campaign.segment_id,
|
|
"scheduled_at": campaign.scheduled_at.isoformat() if campaign.scheduled_at else None,
|
|
"total_recipients": campaign.total_recipients,
|
|
"total_sent": campaign.total_sent,
|
|
"total_delivered": campaign.total_delivered,
|
|
"total_opened": campaign.total_opened,
|
|
"total_clicked": campaign.total_clicked,
|
|
"total_bounced": campaign.total_bounced,
|
|
"open_rate": float(campaign.open_rate) if campaign.open_rate else None,
|
|
"click_rate": float(campaign.click_rate) if campaign.click_rate else None,
|
|
"created_at": campaign.created_at.isoformat() if campaign.created_at else None
|
|
}
|
|
|
|
@router.post("")
|
|
async def create_campaign(
|
|
data: CampaignCreate,
|
|
current_user: User = Depends(authorize_roles("admin")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create a new email campaign"""
|
|
try:
|
|
campaign_type = CampaignType(data.campaign_type)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid campaign type")
|
|
|
|
campaign = email_campaign_service.create_campaign(
|
|
db=db,
|
|
name=data.name,
|
|
subject=data.subject,
|
|
html_content=data.html_content,
|
|
text_content=data.text_content,
|
|
campaign_type=campaign_type,
|
|
segment_id=data.segment_id,
|
|
scheduled_at=data.scheduled_at,
|
|
template_id=data.template_id,
|
|
created_by=current_user.id,
|
|
from_name=data.from_name,
|
|
from_email=data.from_email,
|
|
reply_to_email=data.reply_to_email,
|
|
track_opens=data.track_opens,
|
|
track_clicks=data.track_clicks
|
|
)
|
|
|
|
return {"status": "success", "campaign_id": campaign.id}
|
|
|
|
@router.put("/{campaign_id}")
|
|
async def update_campaign(
|
|
campaign_id: int,
|
|
data: CampaignUpdate,
|
|
current_user: User = Depends(authorize_roles("admin")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Update a campaign"""
|
|
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
|
if not campaign:
|
|
raise HTTPException(status_code=404, detail="Campaign not found")
|
|
|
|
if data.name:
|
|
campaign.name = data.name
|
|
if data.subject:
|
|
campaign.subject = data.subject
|
|
if data.html_content:
|
|
campaign.html_content = data.html_content
|
|
if data.text_content is not None:
|
|
campaign.text_content = data.text_content
|
|
if data.segment_id is not None:
|
|
campaign.segment_id = int(data.segment_id) if isinstance(data.segment_id, str) else data.segment_id
|
|
if data.scheduled_at is not None:
|
|
campaign.scheduled_at = data.scheduled_at
|
|
if data.status:
|
|
try:
|
|
campaign.status = CampaignStatus(data.status)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid status")
|
|
|
|
db.commit()
|
|
db.refresh(campaign)
|
|
|
|
return {"status": "success", "message": "Campaign updated"}
|
|
|
|
@router.post("/{campaign_id}/send")
|
|
async def send_campaign(
|
|
campaign_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(authorize_roles("admin"))
|
|
):
|
|
"""Send an email campaign"""
|
|
try:
|
|
result = email_campaign_service.send_campaign(db=db, campaign_id=campaign_id)
|
|
return {"status": "success", "result": result}
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to send campaign: {str(e)}")
|
|
|
|
@router.get("/{campaign_id}/analytics")
|
|
async def get_campaign_analytics(
|
|
campaign_id: int,
|
|
current_user: User = Depends(authorize_roles("admin")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get campaign analytics"""
|
|
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
|
if not campaign:
|
|
raise HTTPException(status_code=404, detail="Campaign not found")
|
|
|
|
# Get email status breakdown
|
|
emails = db.query(CampaignEmail).filter(CampaignEmail.campaign_id == campaign_id).all()
|
|
|
|
status_breakdown = {}
|
|
for status in EmailStatus:
|
|
status_breakdown[status.value] = len([e for e in emails if e.status == status])
|
|
|
|
return {
|
|
"campaign_id": campaign.id,
|
|
"total_recipients": campaign.total_recipients,
|
|
"total_sent": campaign.total_sent,
|
|
"total_delivered": campaign.total_delivered,
|
|
"total_opened": campaign.total_opened,
|
|
"total_clicked": campaign.total_clicked,
|
|
"total_bounced": campaign.total_bounced,
|
|
"total_unsubscribed": campaign.total_unsubscribed,
|
|
"open_rate": float(campaign.open_rate) if campaign.open_rate else 0,
|
|
"click_rate": float(campaign.click_rate) if campaign.click_rate else 0,
|
|
"bounce_rate": float(campaign.bounce_rate) if campaign.bounce_rate else 0,
|
|
"status_breakdown": status_breakdown
|
|
}
|
|
|
|
# Tracking Routes (public endpoints for email tracking)
|
|
@router.get("/track/open/{campaign_email_id}")
|
|
async def track_email_open(
|
|
campaign_email_id: int,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Track email open (called by tracking pixel)"""
|
|
email_campaign_service.track_email_open(db=db, campaign_email_id=campaign_email_id)
|
|
# Return 1x1 transparent pixel (GIF)
|
|
from fastapi.responses import Response
|
|
# 1x1 transparent GIF
|
|
pixel = b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00\x21\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x04\x01\x00\x3b'
|
|
return Response(content=pixel, media_type="image/gif")
|
|
|
|
@router.get("/track/click/{campaign_email_id}")
|
|
async def track_email_click(
|
|
campaign_email_id: int,
|
|
url: str = Query(...),
|
|
request: Request = None,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Track email click"""
|
|
ip_address = request.client.host if request and request.client else None
|
|
user_agent = request.headers.get("User-Agent") if request else None
|
|
|
|
email_campaign_service.track_email_click(
|
|
db=db,
|
|
campaign_email_id=campaign_email_id,
|
|
url=url,
|
|
ip_address=ip_address,
|
|
user_agent=user_agent
|
|
)
|
|
|
|
# Redirect to the actual URL
|
|
from fastapi.responses import RedirectResponse
|
|
return RedirectResponse(url=url)
|
|
|
|
# Unsubscribe Routes
|
|
@router.post("/unsubscribe")
|
|
async def unsubscribe(
|
|
email: EmailStr = Query(...),
|
|
campaign_id: Optional[Union[int, str]] = Query(None),
|
|
unsubscribe_all: bool = Query(False),
|
|
reason: Optional[str] = None,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Unsubscribe from email campaigns"""
|
|
# Parse campaign_id if it's a string
|
|
parsed_campaign_id = None
|
|
if campaign_id is not None and campaign_id != '' and campaign_id != 'undefined':
|
|
try:
|
|
parsed_campaign_id = int(campaign_id) if isinstance(campaign_id, str) else campaign_id
|
|
except (ValueError, TypeError):
|
|
parsed_campaign_id = None
|
|
|
|
user = db.query(User).filter(User.email == email).first()
|
|
|
|
unsubscribe_record = Unsubscribe(
|
|
email=email,
|
|
user_id=user.id if user else None,
|
|
campaign_id=parsed_campaign_id,
|
|
unsubscribe_all=unsubscribe_all,
|
|
reason=reason
|
|
)
|
|
db.add(unsubscribe_record)
|
|
db.commit()
|
|
|
|
return {"status": "success", "message": "Successfully unsubscribed"}
|
|
|
|
@router.post("/drip-sequences/process")
|
|
async def process_drip_sequences(
|
|
current_user: User = Depends(authorize_roles("admin")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Manually trigger drip sequence processing"""
|
|
try:
|
|
email_campaign_service.process_drip_sequences(db=db)
|
|
return {"status": "success", "message": "Drip sequences processed"}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
from ..config.logging_config import get_logger
|
|
logger = get_logger(__name__)
|
|
logger.error(f"Error processing drip sequences: {str(e)}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to process drip sequences: {str(e)}")
|
|
|