Some checks failed
Move db.commit() from services to API endpoints and Celery tasks. Services now use db.flush() only; endpoints own the transaction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
189 lines
6.5 KiB
Python
189 lines
6.5 KiB
Python
# app/modules/prospecting/services/campaign_service.py
|
|
"""
|
|
Campaign management service.
|
|
|
|
Handles campaign template CRUD, rendering with prospect data,
|
|
and send tracking.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.modules.prospecting.exceptions import (
|
|
CampaignRenderException,
|
|
CampaignTemplateNotFoundException,
|
|
)
|
|
from app.modules.prospecting.models import (
|
|
CampaignSend,
|
|
CampaignSendStatus,
|
|
CampaignTemplate,
|
|
Prospect,
|
|
)
|
|
from app.modules.prospecting.services.prospect_service import prospect_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CampaignService:
|
|
"""Service for campaign template management and sending."""
|
|
|
|
# --- Template CRUD ---
|
|
|
|
def get_templates(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
lead_type: str | None = None,
|
|
active_only: bool = False,
|
|
) -> list[CampaignTemplate]:
|
|
query = db.query(CampaignTemplate)
|
|
if lead_type:
|
|
query = query.filter(CampaignTemplate.lead_type == lead_type)
|
|
if active_only:
|
|
query = query.filter(CampaignTemplate.is_active.is_(True))
|
|
return query.order_by(CampaignTemplate.lead_type, CampaignTemplate.name).all()
|
|
|
|
def get_template_by_id(self, db: Session, template_id: int) -> CampaignTemplate:
|
|
template = db.query(CampaignTemplate).filter(CampaignTemplate.id == template_id).first()
|
|
if not template:
|
|
raise CampaignTemplateNotFoundException(str(template_id))
|
|
return template
|
|
|
|
def create_template(self, db: Session, data: dict) -> CampaignTemplate:
|
|
template = CampaignTemplate(
|
|
name=data["name"],
|
|
lead_type=data["lead_type"],
|
|
channel=data.get("channel", "email"),
|
|
language=data.get("language", "fr"),
|
|
subject_template=data.get("subject_template"),
|
|
body_template=data["body_template"],
|
|
is_active=data.get("is_active", True),
|
|
)
|
|
db.add(template)
|
|
db.flush()
|
|
return template
|
|
|
|
def update_template(self, db: Session, template_id: int, data: dict) -> CampaignTemplate:
|
|
template = self.get_template_by_id(db, template_id)
|
|
for field in ["name", "lead_type", "channel", "language", "subject_template", "body_template", "is_active"]:
|
|
if field in data and data[field] is not None:
|
|
setattr(template, field, data[field])
|
|
db.flush()
|
|
return template
|
|
|
|
def delete_template(self, db: Session, template_id: int) -> bool:
|
|
template = self.get_template_by_id(db, template_id)
|
|
db.delete(template)
|
|
db.flush()
|
|
return True
|
|
|
|
# --- Rendering ---
|
|
|
|
def render_campaign(self, db: Session, template_id: int, prospect_id: int) -> dict:
|
|
"""Render a campaign template with prospect data."""
|
|
template = self.get_template_by_id(db, template_id)
|
|
prospect = prospect_service.get_by_id(db, prospect_id)
|
|
|
|
placeholders = self._build_placeholders(prospect)
|
|
|
|
try:
|
|
rendered_subject = None
|
|
if template.subject_template:
|
|
rendered_subject = template.subject_template.format(**placeholders)
|
|
rendered_body = template.body_template.format(**placeholders)
|
|
except KeyError as e:
|
|
raise CampaignRenderException(template_id, f"Missing placeholder: {e}")
|
|
|
|
return {
|
|
"subject": rendered_subject,
|
|
"body": rendered_body,
|
|
}
|
|
|
|
# --- Sending ---
|
|
|
|
def send_campaign(
|
|
self,
|
|
db: Session,
|
|
template_id: int,
|
|
prospect_ids: list[int],
|
|
sent_by_user_id: int,
|
|
) -> list[CampaignSend]:
|
|
"""Create campaign send records for prospects."""
|
|
template = self.get_template_by_id(db, template_id)
|
|
sends = []
|
|
|
|
for pid in prospect_ids:
|
|
prospect = prospect_service.get_by_id(db, pid)
|
|
placeholders = self._build_placeholders(prospect)
|
|
|
|
try:
|
|
rendered_subject = None
|
|
if template.subject_template:
|
|
rendered_subject = template.subject_template.format(**placeholders)
|
|
rendered_body = template.body_template.format(**placeholders)
|
|
except KeyError:
|
|
rendered_body = template.body_template
|
|
rendered_subject = template.subject_template
|
|
|
|
send = CampaignSend(
|
|
template_id=template_id,
|
|
prospect_id=pid,
|
|
channel=template.channel,
|
|
rendered_subject=rendered_subject,
|
|
rendered_body=rendered_body,
|
|
status=CampaignSendStatus.SENT,
|
|
sent_at=datetime.now(UTC),
|
|
sent_by_user_id=sent_by_user_id,
|
|
)
|
|
db.add(send)
|
|
sends.append(send)
|
|
|
|
db.flush()
|
|
logger.info("Sent campaign %d to %d prospects", template_id, len(prospect_ids))
|
|
return sends
|
|
|
|
def get_sends(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
prospect_id: int | None = None,
|
|
template_id: int | None = None,
|
|
) -> list[CampaignSend]:
|
|
query = db.query(CampaignSend)
|
|
if prospect_id:
|
|
query = query.filter(CampaignSend.prospect_id == prospect_id)
|
|
if template_id:
|
|
query = query.filter(CampaignSend.template_id == template_id)
|
|
return query.order_by(CampaignSend.created_at.desc()).all()
|
|
|
|
def _build_placeholders(self, prospect: Prospect) -> dict:
|
|
"""Build template placeholder values from prospect data."""
|
|
contacts = prospect.contacts or []
|
|
primary_email = next((c.value for c in contacts if c.contact_type == "email"), "")
|
|
primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), "")
|
|
|
|
reason_flags = []
|
|
if prospect.score and prospect.score.reason_flags:
|
|
try:
|
|
reason_flags = json.loads(prospect.score.reason_flags)
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
|
|
issues_text = ", ".join(f.replace("_", " ") for f in reason_flags)
|
|
|
|
return {
|
|
"business_name": prospect.business_name or prospect.domain_name or "",
|
|
"domain": prospect.domain_name or "",
|
|
"score": str(prospect.score.score) if prospect.score else "—",
|
|
"issues": issues_text,
|
|
"primary_email": primary_email,
|
|
"primary_phone": primary_phone,
|
|
"city": prospect.city or "Luxembourg",
|
|
}
|
|
|
|
|
|
campaign_service = CampaignService()
|