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