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:
1
app/modules/prospecting/routes/api/__init__.py
Normal file
1
app/modules/prospecting/routes/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/prospecting/routes/api/__init__.py
|
||||
30
app/modules/prospecting/routes/api/admin.py
Normal file
30
app/modules/prospecting/routes/api/admin.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# app/modules/prospecting/routes/api/admin.py
|
||||
"""
|
||||
Prospecting module admin API routes.
|
||||
|
||||
Aggregates all admin prospecting routes:
|
||||
- /prospects/* - Prospect CRUD and import
|
||||
- /enrichment/* - Scanning pipeline
|
||||
- /leads/* - Lead filtering and export
|
||||
- /stats/* - Dashboard statistics
|
||||
- /interactions/* - Interaction logging
|
||||
- /campaigns/* - Campaign management
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .admin_campaigns import router as admin_campaigns_router
|
||||
from .admin_enrichment import router as admin_enrichment_router
|
||||
from .admin_interactions import router as admin_interactions_router
|
||||
from .admin_leads import router as admin_leads_router
|
||||
from .admin_prospects import router as admin_prospects_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(admin_prospects_router, tags=["prospecting-prospects"])
|
||||
router.include_router(admin_enrichment_router, tags=["prospecting-enrichment"])
|
||||
router.include_router(admin_leads_router, tags=["prospecting-leads"])
|
||||
router.include_router(admin_stats_router, tags=["prospecting-stats"])
|
||||
router.include_router(admin_interactions_router, tags=["prospecting-interactions"])
|
||||
router.include_router(admin_campaigns_router, tags=["prospecting-campaigns"])
|
||||
116
app/modules/prospecting/routes/api/admin_campaigns.py
Normal file
116
app/modules/prospecting/routes/api/admin_campaigns.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# app/modules/prospecting/routes/api/admin_campaigns.py
|
||||
"""
|
||||
Admin API routes for campaign management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.prospecting.schemas.campaign import (
|
||||
CampaignPreviewRequest,
|
||||
CampaignPreviewResponse,
|
||||
CampaignSendCreate,
|
||||
CampaignSendListResponse,
|
||||
CampaignSendResponse,
|
||||
CampaignTemplateCreate,
|
||||
CampaignTemplateDeleteResponse,
|
||||
CampaignTemplateResponse,
|
||||
CampaignTemplateUpdate,
|
||||
)
|
||||
from app.modules.prospecting.services.campaign_service import campaign_service
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
router = APIRouter(prefix="/campaigns")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/templates", response_model=list[CampaignTemplateResponse])
|
||||
def list_templates(
|
||||
lead_type: str | None = Query(None),
|
||||
active_only: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""List campaign templates with optional filters."""
|
||||
templates = campaign_service.get_templates(db, lead_type=lead_type, active_only=active_only)
|
||||
return [CampaignTemplateResponse.model_validate(t) for t in templates]
|
||||
|
||||
|
||||
@router.post("/templates", response_model=CampaignTemplateResponse)
|
||||
def create_template(
|
||||
data: CampaignTemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Create a new campaign template."""
|
||||
template = campaign_service.create_template(db, data.model_dump())
|
||||
return CampaignTemplateResponse.model_validate(template)
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}", response_model=CampaignTemplateResponse)
|
||||
def update_template(
|
||||
data: CampaignTemplateUpdate,
|
||||
template_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Update a campaign template."""
|
||||
template = campaign_service.update_template(db, template_id, data.model_dump(exclude_none=True))
|
||||
return CampaignTemplateResponse.model_validate(template)
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}", response_model=CampaignTemplateDeleteResponse)
|
||||
def delete_template(
|
||||
template_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Delete a campaign template."""
|
||||
campaign_service.delete_template(db, template_id)
|
||||
return CampaignTemplateDeleteResponse(message="Template deleted")
|
||||
|
||||
|
||||
@router.post("/preview", response_model=CampaignPreviewResponse)
|
||||
def preview_campaign(
|
||||
data: CampaignPreviewRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Preview a rendered campaign for a specific prospect."""
|
||||
result = campaign_service.render_campaign(db, data.template_id, data.prospect_id)
|
||||
return CampaignPreviewResponse(**result)
|
||||
|
||||
|
||||
@router.post("/send", response_model=list[CampaignSendResponse])
|
||||
def send_campaign(
|
||||
data: CampaignSendCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Send a campaign to one or more prospects."""
|
||||
sends = campaign_service.send_campaign(
|
||||
db,
|
||||
template_id=data.template_id,
|
||||
prospect_ids=data.prospect_ids,
|
||||
sent_by_user_id=current_admin.user_id,
|
||||
)
|
||||
return [CampaignSendResponse.model_validate(s) for s in sends]
|
||||
|
||||
|
||||
@router.get("/sends", response_model=CampaignSendListResponse)
|
||||
def list_sends(
|
||||
prospect_id: int | None = Query(None),
|
||||
template_id: int | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""List sent campaigns with optional filters."""
|
||||
sends = campaign_service.get_sends(db, prospect_id=prospect_id, template_id=template_id)
|
||||
return CampaignSendListResponse(
|
||||
items=[CampaignSendResponse.model_validate(s) for s in sends],
|
||||
total=len(sends),
|
||||
)
|
||||
177
app/modules/prospecting/routes/api/admin_enrichment.py
Normal file
177
app/modules/prospecting/routes/api/admin_enrichment.py
Normal file
@@ -0,0 +1,177 @@
|
||||
# app/modules/prospecting/routes/api/admin_enrichment.py
|
||||
"""
|
||||
Admin API routes for enrichment/scanning pipeline.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.prospecting.schemas.enrichment import (
|
||||
ContactScrapeResponse,
|
||||
FullEnrichmentResponse,
|
||||
HttpCheckBatchItem,
|
||||
HttpCheckBatchResponse,
|
||||
HttpCheckResult,
|
||||
ScanBatchResponse,
|
||||
ScanSingleResponse,
|
||||
ScoreComputeBatchResponse,
|
||||
)
|
||||
from app.modules.prospecting.services.enrichment_service import enrichment_service
|
||||
from app.modules.prospecting.services.prospect_service import prospect_service
|
||||
from app.modules.prospecting.services.scoring_service import scoring_service
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
router = APIRouter(prefix="/enrichment")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/http-check/{prospect_id}", response_model=HttpCheckResult)
|
||||
def http_check_single(
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Run HTTP connectivity check for a single prospect."""
|
||||
prospect = prospect_service.get_by_id(db, prospect_id)
|
||||
result = enrichment_service.check_http(db, prospect)
|
||||
return HttpCheckResult(**result)
|
||||
|
||||
|
||||
@router.post("/http-check/batch", response_model=HttpCheckBatchResponse)
|
||||
def http_check_batch(
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Run HTTP check for pending prospects."""
|
||||
prospects = prospect_service.get_pending_http_check(db, limit=limit)
|
||||
results = []
|
||||
for prospect in prospects:
|
||||
result = enrichment_service.check_http(db, prospect)
|
||||
results.append(HttpCheckBatchItem(domain=prospect.domain_name, **result))
|
||||
return HttpCheckBatchResponse(processed=len(results), results=results)
|
||||
|
||||
|
||||
@router.post("/tech-scan/{prospect_id}", response_model=ScanSingleResponse)
|
||||
def tech_scan_single(
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Run technology scan for a single prospect."""
|
||||
prospect = prospect_service.get_by_id(db, prospect_id)
|
||||
profile = enrichment_service.scan_tech_stack(db, prospect)
|
||||
return ScanSingleResponse(domain=prospect.domain_name, profile=profile is not None)
|
||||
|
||||
|
||||
@router.post("/tech-scan/batch", response_model=ScanBatchResponse)
|
||||
def tech_scan_batch(
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Run tech scan for pending prospects."""
|
||||
prospects = prospect_service.get_pending_tech_scan(db, limit=limit)
|
||||
count = 0
|
||||
for prospect in prospects:
|
||||
result = enrichment_service.scan_tech_stack(db, prospect)
|
||||
if result:
|
||||
count += 1
|
||||
return ScanBatchResponse(processed=len(prospects), successful=count)
|
||||
|
||||
|
||||
@router.post("/performance/{prospect_id}", response_model=ScanSingleResponse)
|
||||
def performance_scan_single(
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Run PageSpeed audit for a single prospect."""
|
||||
prospect = prospect_service.get_by_id(db, prospect_id)
|
||||
profile = enrichment_service.scan_performance(db, prospect)
|
||||
return ScanSingleResponse(domain=prospect.domain_name, profile=profile is not None)
|
||||
|
||||
|
||||
@router.post("/performance/batch", response_model=ScanBatchResponse)
|
||||
def performance_scan_batch(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Run performance scan for pending prospects."""
|
||||
prospects = prospect_service.get_pending_performance_scan(db, limit=limit)
|
||||
count = 0
|
||||
for prospect in prospects:
|
||||
result = enrichment_service.scan_performance(db, prospect)
|
||||
if result:
|
||||
count += 1
|
||||
return ScanBatchResponse(processed=len(prospects), successful=count)
|
||||
|
||||
|
||||
@router.post("/contacts/{prospect_id}", response_model=ContactScrapeResponse)
|
||||
def scrape_contacts_single(
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Scrape contacts for a single prospect."""
|
||||
prospect = prospect_service.get_by_id(db, prospect_id)
|
||||
contacts = enrichment_service.scrape_contacts(db, prospect)
|
||||
return ContactScrapeResponse(domain=prospect.domain_name, contacts_found=len(contacts))
|
||||
|
||||
|
||||
@router.post("/full/{prospect_id}", response_model=FullEnrichmentResponse)
|
||||
def full_enrichment(
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Run full enrichment pipeline for a single prospect."""
|
||||
prospect = prospect_service.get_by_id(db, prospect_id)
|
||||
|
||||
# Step 1: HTTP check
|
||||
enrichment_service.check_http(db, prospect)
|
||||
|
||||
# Step 2: Tech scan (if has website)
|
||||
tech_profile = None
|
||||
if prospect.has_website:
|
||||
tech_profile = enrichment_service.scan_tech_stack(db, prospect)
|
||||
|
||||
# Step 3: Performance scan (if has website)
|
||||
perf_profile = None
|
||||
if prospect.has_website:
|
||||
perf_profile = enrichment_service.scan_performance(db, prospect)
|
||||
|
||||
# Step 4: Contact scrape (if has website)
|
||||
contacts = []
|
||||
if prospect.has_website:
|
||||
contacts = enrichment_service.scrape_contacts(db, prospect)
|
||||
|
||||
# Step 5: Compute score
|
||||
db.refresh(prospect)
|
||||
score = scoring_service.compute_score(db, prospect)
|
||||
|
||||
return FullEnrichmentResponse(
|
||||
domain=prospect.domain_name,
|
||||
has_website=prospect.has_website,
|
||||
tech_scanned=tech_profile is not None,
|
||||
perf_scanned=perf_profile is not None,
|
||||
contacts_found=len(contacts),
|
||||
score=score.score,
|
||||
lead_tier=score.lead_tier,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/score-compute/batch", response_model=ScoreComputeBatchResponse)
|
||||
def compute_scores_batch(
|
||||
limit: int = Query(500, ge=1, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Compute or recompute scores for all prospects."""
|
||||
count = scoring_service.compute_all(db, limit=limit)
|
||||
return ScoreComputeBatchResponse(scored=count)
|
||||
65
app/modules/prospecting/routes/api/admin_interactions.py
Normal file
65
app/modules/prospecting/routes/api/admin_interactions.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# app/modules/prospecting/routes/api/admin_interactions.py
|
||||
"""
|
||||
Admin API routes for interaction logging and follow-ups.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.prospecting.schemas.interaction import (
|
||||
InteractionCreate,
|
||||
InteractionListResponse,
|
||||
InteractionResponse,
|
||||
)
|
||||
from app.modules.prospecting.services.interaction_service import interaction_service
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/prospects/{prospect_id}/interactions", response_model=InteractionListResponse)
|
||||
def get_prospect_interactions(
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get all interactions for a prospect."""
|
||||
interactions = interaction_service.get_for_prospect(db, prospect_id)
|
||||
return InteractionListResponse(
|
||||
items=[InteractionResponse.model_validate(i) for i in interactions],
|
||||
total=len(interactions),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/prospects/{prospect_id}/interactions", response_model=InteractionResponse)
|
||||
def create_interaction(
|
||||
data: InteractionCreate,
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Log a new interaction for a prospect."""
|
||||
interaction = interaction_service.create(
|
||||
db,
|
||||
prospect_id=prospect_id,
|
||||
user_id=current_admin.user_id,
|
||||
data=data.model_dump(exclude_none=True),
|
||||
)
|
||||
return InteractionResponse.model_validate(interaction)
|
||||
|
||||
|
||||
@router.get("/interactions/upcoming")
|
||||
def get_upcoming_actions(
|
||||
before: date | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get interactions with upcoming follow-up actions."""
|
||||
interactions = interaction_service.get_upcoming_actions(db, before_date=before)
|
||||
return [InteractionResponse.model_validate(i) for i in interactions]
|
||||
99
app/modules/prospecting/routes/api/admin_leads.py
Normal file
99
app/modules/prospecting/routes/api/admin_leads.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# app/modules/prospecting/routes/api/admin_leads.py
|
||||
"""
|
||||
Admin API routes for lead filtering and export.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.prospecting.services.lead_service import lead_service
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
router = APIRouter(prefix="/leads")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_leads(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
min_score: int = Query(0, ge=0, le=100),
|
||||
max_score: int = Query(100, ge=0, le=100),
|
||||
lead_tier: str | None = Query(None),
|
||||
channel: str | None = Query(None),
|
||||
has_email: bool | None = Query(None),
|
||||
has_phone: bool | None = Query(None),
|
||||
reason_flag: str | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get filtered leads with scores."""
|
||||
leads, total = lead_service.get_leads(
|
||||
db,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
min_score=min_score,
|
||||
max_score=max_score,
|
||||
lead_tier=lead_tier,
|
||||
channel=channel,
|
||||
has_email=has_email,
|
||||
has_phone=has_phone,
|
||||
reason_flag=reason_flag,
|
||||
)
|
||||
return {
|
||||
"items": leads,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": ceil(total / per_page) if per_page else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/top-priority")
|
||||
def top_priority_leads(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get top priority leads (score >= 70)."""
|
||||
return lead_service.get_top_priority(db, limit=limit)
|
||||
|
||||
|
||||
@router.get("/quick-wins")
|
||||
def quick_win_leads(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get quick win leads (score 50-69)."""
|
||||
return lead_service.get_quick_wins(db, limit=limit)
|
||||
|
||||
|
||||
@router.get("/export/csv")
|
||||
def export_leads_csv(
|
||||
min_score: int = Query(0, ge=0, le=100),
|
||||
lead_tier: str | None = Query(None),
|
||||
channel: str | None = Query(None),
|
||||
limit: int = Query(1000, ge=1, le=10000),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Export filtered leads as CSV download."""
|
||||
csv_content = lead_service.export_csv(
|
||||
db,
|
||||
min_score=min_score,
|
||||
lead_tier=lead_tier,
|
||||
channel=channel,
|
||||
limit=limit,
|
||||
)
|
||||
return StreamingResponse(
|
||||
iter([csv_content]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=leads_export.csv"},
|
||||
)
|
||||
203
app/modules/prospecting/routes/api/admin_prospects.py
Normal file
203
app/modules/prospecting/routes/api/admin_prospects.py
Normal file
@@ -0,0 +1,203 @@
|
||||
# app/modules/prospecting/routes/api/admin_prospects.py
|
||||
"""
|
||||
Admin API routes for prospect management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.prospecting.schemas.prospect import (
|
||||
ProspectCreate,
|
||||
ProspectDeleteResponse,
|
||||
ProspectDetailResponse,
|
||||
ProspectImportResponse,
|
||||
ProspectListResponse,
|
||||
ProspectResponse,
|
||||
ProspectUpdate,
|
||||
)
|
||||
from app.modules.prospecting.services.prospect_service import prospect_service
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
router = APIRouter(prefix="/prospects")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("", response_model=ProspectListResponse)
|
||||
def list_prospects(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
search: str | None = Query(None),
|
||||
channel: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
tier: str | None = Query(None),
|
||||
city: str | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""List prospects with filters and pagination."""
|
||||
prospects, total = prospect_service.get_all(
|
||||
db,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
search=search,
|
||||
channel=channel,
|
||||
status=status,
|
||||
tier=tier,
|
||||
city=city,
|
||||
)
|
||||
return ProspectListResponse(
|
||||
items=[_to_response(p) for p in prospects],
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=ceil(total / per_page) if per_page else 0,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{prospect_id}", response_model=ProspectDetailResponse)
|
||||
def get_prospect(
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get full prospect detail with all related data."""
|
||||
prospect = prospect_service.get_by_id(db, prospect_id)
|
||||
return prospect
|
||||
|
||||
|
||||
@router.post("", response_model=ProspectResponse)
|
||||
def create_prospect(
|
||||
data: ProspectCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Create a new prospect (digital or offline)."""
|
||||
prospect = prospect_service.create(
|
||||
db,
|
||||
data.model_dump(exclude_none=True),
|
||||
captured_by_user_id=current_admin.user_id,
|
||||
)
|
||||
return _to_response(prospect)
|
||||
|
||||
|
||||
@router.put("/{prospect_id}", response_model=ProspectResponse)
|
||||
def update_prospect(
|
||||
data: ProspectUpdate,
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Update a prospect."""
|
||||
prospect = prospect_service.update(db, prospect_id, data.model_dump(exclude_none=True))
|
||||
return _to_response(prospect)
|
||||
|
||||
|
||||
@router.delete("/{prospect_id}", response_model=ProspectDeleteResponse)
|
||||
def delete_prospect(
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Delete a prospect."""
|
||||
prospect_service.delete(db, prospect_id)
|
||||
return ProspectDeleteResponse(message="Prospect deleted")
|
||||
|
||||
|
||||
@router.post("/import", response_model=ProspectImportResponse)
|
||||
async def import_domains(
|
||||
file: UploadFile,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Import domains from a CSV file."""
|
||||
content = await file.read()
|
||||
lines = content.decode("utf-8").strip().split("\n")
|
||||
# Skip header if present
|
||||
domains = []
|
||||
for line in lines:
|
||||
domain = line.strip().strip(",").strip('"')
|
||||
if domain and "." in domain and not domain.startswith("#"):
|
||||
domains.append(domain)
|
||||
|
||||
created, skipped = prospect_service.create_bulk(db, domains, source="csv_import")
|
||||
return ProspectImportResponse(created=created, skipped=skipped, total=len(domains))
|
||||
|
||||
|
||||
def _to_response(prospect) -> ProspectResponse:
|
||||
"""Convert a prospect model to response schema."""
|
||||
import json
|
||||
|
||||
contacts = prospect.contacts or []
|
||||
primary_email = next((c.value for c in contacts if c.contact_type == "email"), None)
|
||||
primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), None)
|
||||
|
||||
tags = None
|
||||
if prospect.tags:
|
||||
try:
|
||||
tags = json.loads(prospect.tags)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
tags = None
|
||||
|
||||
score_resp = None
|
||||
if prospect.score:
|
||||
from app.modules.prospecting.schemas.score import ProspectScoreResponse
|
||||
|
||||
reason_flags = []
|
||||
if prospect.score.reason_flags:
|
||||
try:
|
||||
reason_flags = json.loads(prospect.score.reason_flags)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
score_breakdown = None
|
||||
if prospect.score.score_breakdown:
|
||||
try:
|
||||
score_breakdown = json.loads(prospect.score.score_breakdown)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
score_resp = ProspectScoreResponse(
|
||||
id=prospect.score.id,
|
||||
prospect_id=prospect.score.prospect_id,
|
||||
score=prospect.score.score,
|
||||
technical_health_score=prospect.score.technical_health_score,
|
||||
modernity_score=prospect.score.modernity_score,
|
||||
business_value_score=prospect.score.business_value_score,
|
||||
engagement_score=prospect.score.engagement_score,
|
||||
reason_flags=reason_flags,
|
||||
score_breakdown=score_breakdown,
|
||||
lead_tier=prospect.score.lead_tier,
|
||||
notes=prospect.score.notes,
|
||||
created_at=prospect.score.created_at,
|
||||
updated_at=prospect.score.updated_at,
|
||||
)
|
||||
|
||||
return ProspectResponse(
|
||||
id=prospect.id,
|
||||
channel=prospect.channel.value if prospect.channel else "digital",
|
||||
business_name=prospect.business_name,
|
||||
domain_name=prospect.domain_name,
|
||||
status=prospect.status.value if prospect.status else "pending",
|
||||
source=prospect.source,
|
||||
has_website=prospect.has_website,
|
||||
uses_https=prospect.uses_https,
|
||||
http_status_code=prospect.http_status_code,
|
||||
address=prospect.address,
|
||||
city=prospect.city,
|
||||
postal_code=prospect.postal_code,
|
||||
country=prospect.country,
|
||||
notes=prospect.notes,
|
||||
tags=tags,
|
||||
location_lat=prospect.location_lat,
|
||||
location_lng=prospect.location_lng,
|
||||
created_at=prospect.created_at,
|
||||
updated_at=prospect.updated_at,
|
||||
score=score_resp,
|
||||
primary_email=primary_email,
|
||||
primary_phone=primary_phone,
|
||||
)
|
||||
50
app/modules/prospecting/routes/api/admin_stats.py
Normal file
50
app/modules/prospecting/routes/api/admin_stats.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# app/modules/prospecting/routes/api/admin_stats.py
|
||||
"""
|
||||
Admin API routes for dashboard statistics.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.prospecting.schemas.scan_job import (
|
||||
ScanJobListResponse,
|
||||
ScanJobResponse,
|
||||
)
|
||||
from app.modules.prospecting.services.stats_service import stats_service
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
router = APIRouter(prefix="/stats")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_overview_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get overview statistics for the dashboard."""
|
||||
return stats_service.get_overview(db)
|
||||
|
||||
|
||||
@router.get("/jobs", response_model=ScanJobListResponse)
|
||||
def list_scan_jobs(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
status: str | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get paginated scan jobs."""
|
||||
jobs, total = stats_service.get_scan_jobs(db, page=page, per_page=per_page, status=status)
|
||||
return ScanJobListResponse(
|
||||
items=[ScanJobResponse.model_validate(j) for j in jobs],
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=ceil(total / per_page) if per_page else 0,
|
||||
)
|
||||
Reference in New Issue
Block a user