Files
orion/app/modules/prospecting/services/prospect_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

234 lines
7.6 KiB
Python

# app/modules/prospecting/services/prospect_service.py
"""
Prospect CRUD service.
Manages creation, retrieval, update, and deletion of prospects.
Supports both digital (domain scan) and offline (manual capture) channels.
"""
import json
import logging
from sqlalchemy import func, or_
from sqlalchemy.orm import Session, joinedload
from app.modules.prospecting.exceptions import (
DuplicateDomainException,
ProspectNotFoundException,
)
from app.modules.prospecting.models import (
Prospect,
ProspectChannel,
ProspectContact,
ProspectScore,
ProspectStatus,
)
logger = logging.getLogger(__name__)
class ProspectService:
"""Service for prospect CRUD operations."""
def get_by_id(self, db: Session, prospect_id: int) -> Prospect:
prospect = (
db.query(Prospect)
.options(
joinedload(Prospect.tech_profile),
joinedload(Prospect.performance_profile),
joinedload(Prospect.score),
joinedload(Prospect.contacts),
)
.filter(Prospect.id == prospect_id)
.first()
)
if not prospect:
raise ProspectNotFoundException(str(prospect_id))
return prospect
def get_by_domain(self, db: Session, domain_name: str) -> Prospect | None:
return db.query(Prospect).filter(Prospect.domain_name == domain_name).first()
def get_all(
self,
db: Session,
*,
page: int = 1,
per_page: int = 20,
search: str | None = None,
channel: str | None = None,
status: str | None = None,
tier: str | None = None,
city: str | None = None,
has_email: bool | None = None,
has_phone: bool | None = None,
) -> tuple[list[Prospect], int]:
query = db.query(Prospect).options(
joinedload(Prospect.score),
joinedload(Prospect.contacts),
)
if search:
query = query.filter(
or_(
Prospect.domain_name.ilike(f"%{search}%"),
Prospect.business_name.ilike(f"%{search}%"),
)
)
if channel:
query = query.filter(Prospect.channel == channel)
if status:
query = query.filter(Prospect.status == status)
if city:
query = query.filter(Prospect.city.ilike(f"%{city}%"))
if tier:
query = query.join(ProspectScore).filter(ProspectScore.lead_tier == tier)
total = query.count()
prospects = (
query.order_by(Prospect.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
return prospects, total
def create(self, db: Session, data: dict, captured_by_user_id: int | None = None) -> Prospect:
channel = data.get("channel", "digital")
if channel == "digital" and data.get("domain_name"):
existing = self.get_by_domain(db, data["domain_name"])
if existing:
raise DuplicateDomainException(data["domain_name"])
tags = data.get("tags")
if isinstance(tags, list):
tags = json.dumps(tags)
prospect = Prospect(
channel=ProspectChannel(channel),
business_name=data.get("business_name"),
domain_name=data.get("domain_name"),
status=ProspectStatus.PENDING,
source=data.get("source", "domain_scan" if channel == "digital" else "manual"),
address=data.get("address"),
city=data.get("city"),
postal_code=data.get("postal_code"),
country=data.get("country", "LU"),
notes=data.get("notes"),
tags=tags,
captured_by_user_id=captured_by_user_id,
location_lat=data.get("location_lat"),
location_lng=data.get("location_lng"),
)
db.add(prospect)
db.flush()
# Create inline contacts if provided
contacts = data.get("contacts", [])
for c in contacts:
contact = ProspectContact(
prospect_id=prospect.id,
contact_type=c["contact_type"],
value=c["value"],
label=c.get("label"),
is_primary=c.get("is_primary", False),
)
db.add(contact)
db.flush()
logger.info("Created prospect: %s (channel=%s)", prospect.display_name, channel)
return prospect
def create_bulk(self, db: Session, domain_names: list[str], source: str = "csv_import") -> tuple[int, int]:
created = 0
skipped = 0
for name in domain_names:
name = name.strip().lower()
if not name:
continue
existing = self.get_by_domain(db, name)
if existing:
skipped += 1
continue
prospect = Prospect(
channel=ProspectChannel.DIGITAL,
domain_name=name,
source=source,
)
db.add(prospect)
created += 1
db.flush()
logger.info("Bulk import: %d created, %d skipped", created, skipped)
return created, skipped
def update(self, db: Session, prospect_id: int, data: dict) -> Prospect:
prospect = self.get_by_id(db, prospect_id)
for field in ["business_name", "status", "source", "address", "city", "postal_code", "notes"]:
if field in data and data[field] is not None:
setattr(prospect, field, data[field])
if "tags" in data:
tags = data["tags"]
if isinstance(tags, list):
tags = json.dumps(tags)
prospect.tags = tags
db.flush()
return prospect
def delete(self, db: Session, prospect_id: int) -> bool:
prospect = self.get_by_id(db, prospect_id)
db.delete(prospect)
db.flush()
logger.info("Deleted prospect: %d", prospect_id)
return True
def get_pending_http_check(self, db: Session, limit: int = 100) -> list[Prospect]:
return (
db.query(Prospect)
.filter(
Prospect.channel == ProspectChannel.DIGITAL,
Prospect.domain_name.isnot(None),
Prospect.last_http_check_at.is_(None),
)
.limit(limit)
.all()
)
def get_pending_tech_scan(self, db: Session, limit: int = 100) -> list[Prospect]:
return (
db.query(Prospect)
.filter(
Prospect.has_website.is_(True),
Prospect.last_tech_scan_at.is_(None),
)
.limit(limit)
.all()
)
def get_pending_performance_scan(self, db: Session, limit: int = 100) -> list[Prospect]:
return (
db.query(Prospect)
.filter(
Prospect.has_website.is_(True),
Prospect.last_perf_scan_at.is_(None),
)
.limit(limit)
.all()
)
def count_by_status(self, db: Session) -> dict[str, int]:
results = db.query(Prospect.status, func.count(Prospect.id)).group_by(Prospect.status).all()
return {status.value if hasattr(status, "value") else str(status): count for status, count in results}
def count_by_channel(self, db: Session) -> dict[str, int]:
results = db.query(Prospect.channel, func.count(Prospect.id)).group_by(Prospect.channel).all()
return {channel.value if hasattr(channel, "value") else str(channel): count for channel, count in results}
prospect_service = ProspectService()