Files
Hotel-Booking/Backend/src/routes/email_campaign_routes.py
Iliyan Angelov 627959f52b updates
2025-11-23 18:59:18 +02:00

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)}")