Files
orion/app/modules/prospecting/services/campaign_service.py
Samir Boulahtit 540205402f
Some checks failed
CI / pytest (push) Waiting to run
CI / ruff (push) Successful in 12s
CI / validate (push) Successful in 26s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
feat(middleware): harden routing with fail-closed policy, custom subdomain management, and perf fixes
- Fix IPv6 host parsing with _strip_port() utility
- Remove dangerous StorePlatform→Store.subdomain silent fallback
- Close storefront gate bypass when frontend_type is None
- Add custom subdomain management UI and API for stores
- Add domain health diagnostic tool
- Convert db.add() in loops to db.add_all() (24 PERF-006 fixes)
- Add tests for all new functionality (18 subdomain service tests)
- Add .github templates for validator compliance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 18:13:01 +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,
)
sends.append(send)
db.add_all(sends)
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()