feat(prospecting): add complete prospecting module for lead discovery and scoring
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

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:
2026-02-28 00:59:47 +01:00
parent a709adaee8
commit 6d6eba75bf
79 changed files with 7551 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
# app/modules/prospecting/schemas/__init__.py
from app.modules.prospecting.schemas.campaign import (
CampaignPreviewRequest,
CampaignPreviewResponse,
CampaignSendCreate,
CampaignSendListResponse,
CampaignSendResponse,
CampaignTemplateCreate,
CampaignTemplateResponse,
CampaignTemplateUpdate,
)
from app.modules.prospecting.schemas.contact import (
ProspectContactCreate,
ProspectContactResponse,
)
from app.modules.prospecting.schemas.interaction import (
InteractionCreate,
InteractionListResponse,
InteractionResponse,
)
from app.modules.prospecting.schemas.performance_profile import (
PerformanceProfileResponse,
)
from app.modules.prospecting.schemas.prospect import (
ProspectCreate,
ProspectDeleteResponse,
ProspectDetailResponse,
ProspectImportResponse,
ProspectListResponse,
ProspectResponse,
ProspectUpdate,
)
from app.modules.prospecting.schemas.scan_job import (
ScanJobListResponse,
ScanJobResponse,
)
from app.modules.prospecting.schemas.score import ProspectScoreResponse
from app.modules.prospecting.schemas.tech_profile import TechProfileResponse
__all__ = [
"ProspectCreate",
"ProspectUpdate",
"ProspectResponse",
"ProspectDetailResponse",
"ProspectListResponse",
"ProspectDeleteResponse",
"ProspectImportResponse",
"TechProfileResponse",
"PerformanceProfileResponse",
"ProspectScoreResponse",
"ProspectContactCreate",
"ProspectContactResponse",
"ScanJobResponse",
"ScanJobListResponse",
"InteractionCreate",
"InteractionResponse",
"InteractionListResponse",
"CampaignTemplateCreate",
"CampaignTemplateUpdate",
"CampaignTemplateResponse",
"CampaignSendCreate",
"CampaignPreviewRequest",
"CampaignPreviewResponse",
"CampaignSendResponse",
"CampaignSendListResponse",
]

View File

@@ -0,0 +1,103 @@
# app/modules/prospecting/schemas/campaign.py
"""Pydantic schemas for campaign management."""
from datetime import datetime
from pydantic import BaseModel, Field
class CampaignTemplateCreate(BaseModel):
"""Schema for creating a campaign template."""
name: str = Field(..., max_length=255)
lead_type: str = Field(
...,
pattern="^(no_website|bad_website|gmail_only|security_issues|performance_issues|outdated_cms|general)$",
)
channel: str = Field("email", pattern="^(email|letter|phone_script)$")
language: str = Field("fr", max_length=5)
subject_template: str | None = Field(None, max_length=500)
body_template: str = Field(...)
is_active: bool = True
class CampaignTemplateUpdate(BaseModel):
"""Schema for updating a campaign template."""
name: str | None = Field(None, max_length=255)
lead_type: str | None = None
channel: str | None = None
language: str | None = Field(None, max_length=5)
subject_template: str | None = None
body_template: str | None = None
is_active: bool | None = None
class CampaignTemplateResponse(BaseModel):
"""Schema for campaign template response."""
id: int
name: str
lead_type: str
channel: str
language: str
subject_template: str | None = None
body_template: str
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class CampaignSendCreate(BaseModel):
"""Schema for sending a campaign."""
template_id: int
prospect_ids: list[int] = Field(..., min_length=1)
class CampaignPreviewRequest(BaseModel):
"""Schema for previewing a rendered campaign."""
template_id: int
prospect_id: int
class CampaignPreviewResponse(BaseModel):
"""Schema for campaign preview response."""
subject: str | None = None
body: str
class CampaignSendResponse(BaseModel):
"""Schema for campaign send record response."""
id: int
template_id: int | None = None
prospect_id: int
channel: str
rendered_subject: str | None = None
rendered_body: str | None = None
status: str
sent_at: datetime | None = None
sent_by_user_id: int | None = None
created_at: datetime
class Config:
from_attributes = True
class CampaignSendListResponse(BaseModel):
"""List of campaign sends."""
items: list[CampaignSendResponse]
total: int
class CampaignTemplateDeleteResponse(BaseModel):
"""Response for template deletion."""
message: str

View File

@@ -0,0 +1,34 @@
# app/modules/prospecting/schemas/contact.py
"""Pydantic schemas for prospect contacts."""
from datetime import datetime
from pydantic import BaseModel, Field
class ProspectContactCreate(BaseModel):
"""Schema for creating a contact."""
contact_type: str = Field(..., pattern="^(email|phone|address|social|form)$")
value: str = Field(..., max_length=500)
label: str | None = Field(None, max_length=100)
is_primary: bool = False
class ProspectContactResponse(BaseModel):
"""Schema for contact response."""
id: int
prospect_id: int
contact_type: str
value: str
label: str | None = None
source_url: str | None = None
source_element: str | None = None
is_validated: bool = False
is_primary: bool = False
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,69 @@
# app/modules/prospecting/schemas/enrichment.py
"""Pydantic response schemas for enrichment/scanning endpoints."""
from pydantic import BaseModel
class HttpCheckResult(BaseModel):
"""Response from a single HTTP check."""
has_website: bool
uses_https: bool | None = None
http_status_code: int | None = None
redirect_url: str | None = None
class HttpCheckBatchItem(BaseModel):
"""Single item in a batch HTTP check response."""
domain: str
has_website: bool
uses_https: bool | None = None
http_status_code: int | None = None
redirect_url: str | None = None
class HttpCheckBatchResponse(BaseModel):
"""Response from batch HTTP check."""
processed: int
results: list[HttpCheckBatchItem]
class ScanSingleResponse(BaseModel):
"""Response from a single scan (tech or performance)."""
domain: str
profile: bool
class ScanBatchResponse(BaseModel):
"""Response from a batch scan."""
processed: int
successful: int
class ContactScrapeResponse(BaseModel):
"""Response from a single contact scrape."""
domain: str
contacts_found: int
class FullEnrichmentResponse(BaseModel):
"""Response from full enrichment pipeline."""
domain: str
has_website: bool | None = None
tech_scanned: bool
perf_scanned: bool
contacts_found: int
score: int
lead_tier: str
class ScoreComputeBatchResponse(BaseModel):
"""Response from batch score computation."""
scored: int

View File

@@ -0,0 +1,44 @@
# app/modules/prospecting/schemas/interaction.py
"""Pydantic schemas for prospect interactions."""
from datetime import date, datetime
from pydantic import BaseModel, Field
class InteractionCreate(BaseModel):
"""Schema for creating an interaction."""
interaction_type: str = Field(
..., pattern="^(note|call|email_sent|email_received|meeting|visit|sms|proposal_sent)$"
)
subject: str | None = Field(None, max_length=255)
notes: str | None = None
outcome: str | None = Field(None, pattern="^(positive|neutral|negative|no_answer)$")
next_action: str | None = Field(None, max_length=255)
next_action_date: date | None = None
class InteractionResponse(BaseModel):
"""Schema for interaction response."""
id: int
prospect_id: int
interaction_type: str
subject: str | None = None
notes: str | None = None
outcome: str | None = None
next_action: str | None = None
next_action_date: date | None = None
created_by_user_id: int
created_at: datetime
class Config:
from_attributes = True
class InteractionListResponse(BaseModel):
"""List of interactions."""
items: list[InteractionResponse]
total: int

View File

@@ -0,0 +1,33 @@
# app/modules/prospecting/schemas/performance_profile.py
"""Pydantic schemas for performance profile."""
from datetime import datetime
from pydantic import BaseModel
class PerformanceProfileResponse(BaseModel):
"""Schema for performance profile response."""
id: int
prospect_id: int
performance_score: int | None = None
accessibility_score: int | None = None
best_practices_score: int | None = None
seo_score: int | None = None
first_contentful_paint_ms: int | None = None
largest_contentful_paint_ms: int | None = None
total_blocking_time_ms: int | None = None
cumulative_layout_shift: float | None = None
speed_index: int | None = None
time_to_interactive_ms: int | None = None
is_mobile_friendly: bool | None = None
viewport_configured: bool | None = None
total_bytes: int | None = None
scan_strategy: str | None = None
scan_error: str | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,122 @@
# app/modules/prospecting/schemas/prospect.py
"""Pydantic schemas for prospect management."""
from datetime import datetime
from pydantic import BaseModel, Field
class ProspectCreate(BaseModel):
"""Schema for creating a prospect."""
channel: str = Field("digital", pattern="^(digital|offline)$")
business_name: str | None = Field(None, max_length=255)
domain_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=100)
address: str | None = Field(None, max_length=500)
city: str | None = Field(None, max_length=100)
postal_code: str | None = Field(None, max_length=10)
country: str = Field("LU", max_length=2)
notes: str | None = None
tags: list[str] | None = None
location_lat: float | None = None
location_lng: float | None = None
contacts: list["ProspectContactCreate"] | None = None
class ProspectUpdate(BaseModel):
"""Schema for updating a prospect."""
business_name: str | None = Field(None, max_length=255)
status: str | None = None
source: str | None = Field(None, max_length=100)
address: str | None = Field(None, max_length=500)
city: str | None = Field(None, max_length=100)
postal_code: str | None = Field(None, max_length=10)
notes: str | None = None
tags: list[str] | None = None
class ProspectResponse(BaseModel):
"""Schema for prospect response."""
id: int
channel: str
business_name: str | None = None
domain_name: str | None = None
status: str
source: str | None = None
has_website: bool | None = None
uses_https: bool | None = None
http_status_code: int | None = None
address: str | None = None
city: str | None = None
postal_code: str | None = None
country: str = "LU"
notes: str | None = None
tags: list[str] | None = None
location_lat: float | None = None
location_lng: float | None = None
created_at: datetime
updated_at: datetime
# Nested (optional, included in detail view)
score: "ProspectScoreResponse | None" = None
primary_email: str | None = None
primary_phone: str | None = None
class Config:
from_attributes = True
class ProspectDetailResponse(ProspectResponse):
"""Full prospect detail with all related data."""
tech_profile: "TechProfileResponse | None" = None
performance_profile: "PerformanceProfileResponse | None" = None
contacts: list["ProspectContactResponse"] = []
class Config:
from_attributes = True
class ProspectListResponse(BaseModel):
"""Paginated prospect list response."""
items: list[ProspectResponse]
total: int
page: int
per_page: int
pages: int
class ProspectDeleteResponse(BaseModel):
"""Response for prospect deletion."""
message: str
class ProspectImportResponse(BaseModel):
"""Response for domain import."""
created: int
skipped: int
total: int
# Forward references resolved at module level
from app.modules.prospecting.schemas.contact import ( # noqa: E402
ProspectContactCreate,
ProspectContactResponse,
)
from app.modules.prospecting.schemas.performance_profile import (
PerformanceProfileResponse, # noqa: E402
)
from app.modules.prospecting.schemas.score import ProspectScoreResponse # noqa: E402
from app.modules.prospecting.schemas.tech_profile import (
TechProfileResponse, # noqa: E402
)
ProspectCreate.model_rebuild()
ProspectResponse.model_rebuild()
ProspectDetailResponse.model_rebuild()

View File

@@ -0,0 +1,38 @@
# app/modules/prospecting/schemas/scan_job.py
"""Pydantic schemas for scan jobs."""
from datetime import datetime
from pydantic import BaseModel
class ScanJobResponse(BaseModel):
"""Schema for scan job response."""
id: int
job_type: str
status: str
total_items: int
processed_items: int
failed_items: int
skipped_items: int
progress_percent: float
started_at: datetime | None = None
completed_at: datetime | None = None
error_log: str | None = None
celery_task_id: str | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class ScanJobListResponse(BaseModel):
"""Paginated scan job list."""
items: list[ScanJobResponse]
total: int
page: int
per_page: int
pages: int

View File

@@ -0,0 +1,27 @@
# app/modules/prospecting/schemas/score.py
"""Pydantic schemas for opportunity scoring."""
from datetime import datetime
from pydantic import BaseModel
class ProspectScoreResponse(BaseModel):
"""Schema for prospect score response."""
id: int
prospect_id: int
score: int
technical_health_score: int
modernity_score: int
business_value_score: int
engagement_score: int
reason_flags: list[str] = []
score_breakdown: dict[str, int] | None = None
lead_tier: str | None = None
notes: str | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,33 @@
# app/modules/prospecting/schemas/tech_profile.py
"""Pydantic schemas for technology profile."""
from datetime import datetime
from pydantic import BaseModel
class TechProfileResponse(BaseModel):
"""Schema for technology profile response."""
id: int
prospect_id: int
cms: str | None = None
cms_version: str | None = None
server: str | None = None
server_version: str | None = None
hosting_provider: str | None = None
cdn: str | None = None
has_valid_cert: bool | None = None
cert_issuer: str | None = None
cert_expires_at: datetime | None = None
js_framework: str | None = None
analytics: str | None = None
tag_manager: str | None = None
ecommerce_platform: str | None = None
scan_source: str | None = None
scan_error: str | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True