Files
orion/app/modules/prospecting/services/campaign_service.py
Samir Boulahtit 6d6eba75bf
Some checks failed
CI / pytest (push) Failing after 48m31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 11s
CI / validate (push) Successful in 23s
CI / dependency-scanning (push) Successful in 28s
feat(prospecting): add complete prospecting module for lead discovery and scoring
Migrates scanning pipeline from marketing-.lu-domains app into Orion module.
Supports digital (domain scan) and offline (manual capture) lead channels
with enrichment, scoring, campaign management, and interaction tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 00:59:47 +01:00

191 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.commit()
db.refresh(template)
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.commit()
db.refresh(template)
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.commit()
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.commit()
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()