Files
orion/app/modules/prospecting/services/campaign_service.py
Samir Boulahtit 22ae63b414
Some checks failed
CI / validate (push) Has been cancelled
CI / ruff (push) Successful in 10s
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
refactor(prospecting): migrate SVC-006 transaction control to endpoint level
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>
2026-02-28 16:40:09 +01:00

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()