feat(prospecting): add complete prospecting module for lead discovery and scoring
Some checks failed
Some checks failed
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>
This commit is contained in:
190
app/modules/prospecting/services/campaign_service.py
Normal file
190
app/modules/prospecting/services/campaign_service.py
Normal file
@@ -0,0 +1,190 @@
|
||||
# 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()
|
||||
Reference in New Issue
Block a user