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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -190,3 +190,6 @@ static/shared/css/tailwind.css
|
||||
# Export files
|
||||
orion_letzshop_export_*.csv
|
||||
exports/
|
||||
|
||||
# Security audit (needs revamping)
|
||||
scripts/security-audit/
|
||||
|
||||
37
app/modules/prospecting/__init__.py
Normal file
37
app/modules/prospecting/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# app/modules/prospecting/__init__.py
|
||||
"""
|
||||
Prospecting Module - Lead discovery, scoring, and campaign management.
|
||||
|
||||
This is a self-contained module providing:
|
||||
- Domain scanning and website analysis for Luxembourg businesses
|
||||
- Offline lead capture (street encounters, networking)
|
||||
- Opportunity scoring algorithm
|
||||
- Marketing campaign management tailored by lead type
|
||||
- Interaction tracking and follow-up reminders
|
||||
|
||||
Module Structure:
|
||||
- models/ - Database models (prospects, tech profiles, scores, campaigns, etc.)
|
||||
- services/ - Business logic (enrichment, scoring, lead filtering, campaigns)
|
||||
- schemas/ - Pydantic DTOs
|
||||
- routes/ - API and page routes (admin only)
|
||||
- tasks/ - Celery background tasks for batch scanning
|
||||
- exceptions.py - Module-specific exceptions
|
||||
"""
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy import module components to avoid circular imports."""
|
||||
if name == "prospecting_module":
|
||||
from app.modules.prospecting.definition import prospecting_module
|
||||
|
||||
return prospecting_module
|
||||
if name == "get_prospecting_module_with_routers":
|
||||
from app.modules.prospecting.definition import (
|
||||
get_prospecting_module_with_routers,
|
||||
)
|
||||
|
||||
return get_prospecting_module_with_routers
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = ["prospecting_module", "get_prospecting_module_with_routers"]
|
||||
34
app/modules/prospecting/config.py
Normal file
34
app/modules/prospecting/config.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# app/modules/prospecting/config.py
|
||||
"""
|
||||
Module configuration.
|
||||
|
||||
Environment-based configuration using Pydantic Settings.
|
||||
Settings are loaded from environment variables with PROSPECTING_ prefix.
|
||||
|
||||
Example:
|
||||
PROSPECTING_PAGESPEED_API_KEY=your_key
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ModuleConfig(BaseSettings):
|
||||
"""Configuration for prospecting module."""
|
||||
|
||||
# PageSpeed Insights API key (optional, free 25k/day without key)
|
||||
pagespeed_api_key: str = ""
|
||||
|
||||
# HTTP request timeout in seconds
|
||||
http_timeout: int = 10
|
||||
|
||||
# Batch operation limits
|
||||
batch_size: int = 100
|
||||
|
||||
# Max concurrent HTTP requests for batch scanning
|
||||
max_concurrent_requests: int = 10
|
||||
|
||||
model_config = {"env_prefix": "PROSPECTING_"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
config_class = ModuleConfig
|
||||
config = ModuleConfig()
|
||||
165
app/modules/prospecting/definition.py
Normal file
165
app/modules/prospecting/definition.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# app/modules/prospecting/definition.py
|
||||
"""
|
||||
Prospecting module definition.
|
||||
|
||||
Lead discovery, scoring, and campaign management for Luxembourg businesses.
|
||||
Supports digital (domain scanning) and offline (street/networking) lead channels.
|
||||
Admin-only module for superadmin and platform admin users.
|
||||
"""
|
||||
|
||||
from app.modules.base import (
|
||||
MenuItemDefinition,
|
||||
MenuSectionDefinition,
|
||||
ModuleDefinition,
|
||||
PermissionDefinition,
|
||||
)
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
|
||||
def _get_admin_api_router():
|
||||
"""Lazy import of admin API router to avoid circular imports."""
|
||||
from app.modules.prospecting.routes.api.admin import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _get_admin_page_router():
|
||||
"""Lazy import of admin page router to avoid circular imports."""
|
||||
from app.modules.prospecting.routes.pages.admin import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
prospecting_module = ModuleDefinition(
|
||||
code="prospecting",
|
||||
name="Prospecting",
|
||||
description="Lead discovery, scoring, and campaign management for Luxembourg businesses.",
|
||||
version="1.0.0",
|
||||
is_core=False,
|
||||
is_self_contained=True,
|
||||
permissions=[
|
||||
PermissionDefinition(
|
||||
id="prospecting.view",
|
||||
label_key="prospecting.permissions.view",
|
||||
description_key="prospecting.permissions.view_desc",
|
||||
category="prospecting",
|
||||
),
|
||||
PermissionDefinition(
|
||||
id="prospecting.manage",
|
||||
label_key="prospecting.permissions.manage",
|
||||
description_key="prospecting.permissions.manage_desc",
|
||||
category="prospecting",
|
||||
),
|
||||
PermissionDefinition(
|
||||
id="prospecting.scan",
|
||||
label_key="prospecting.permissions.scan",
|
||||
description_key="prospecting.permissions.scan_desc",
|
||||
category="prospecting",
|
||||
),
|
||||
PermissionDefinition(
|
||||
id="prospecting.campaigns",
|
||||
label_key="prospecting.permissions.campaigns",
|
||||
description_key="prospecting.permissions.campaigns_desc",
|
||||
category="prospecting",
|
||||
),
|
||||
PermissionDefinition(
|
||||
id="prospecting.export",
|
||||
label_key="prospecting.permissions.export",
|
||||
description_key="prospecting.permissions.export_desc",
|
||||
category="prospecting",
|
||||
),
|
||||
],
|
||||
features=[
|
||||
"domain_scanning",
|
||||
"offline_capture",
|
||||
"opportunity_scoring",
|
||||
"campaign_management",
|
||||
"lead_export",
|
||||
],
|
||||
menu_items={
|
||||
FrontendType.ADMIN: [
|
||||
"prospecting-dashboard",
|
||||
"prospects",
|
||||
"leads",
|
||||
"capture",
|
||||
"scan-jobs",
|
||||
"campaigns",
|
||||
],
|
||||
},
|
||||
menus={
|
||||
FrontendType.ADMIN: [
|
||||
MenuSectionDefinition(
|
||||
id="prospecting",
|
||||
label_key="prospecting.menu.prospecting",
|
||||
icon="target",
|
||||
order=60,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="prospecting-dashboard",
|
||||
label_key="prospecting.menu.dashboard",
|
||||
icon="chart-bar",
|
||||
route="/admin/prospecting",
|
||||
order=1,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="prospects",
|
||||
label_key="prospecting.menu.prospects",
|
||||
icon="globe",
|
||||
route="/admin/prospecting/prospects",
|
||||
order=5,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="leads",
|
||||
label_key="prospecting.menu.leads",
|
||||
icon="target",
|
||||
route="/admin/prospecting/leads",
|
||||
order=10,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="capture",
|
||||
label_key="prospecting.menu.capture",
|
||||
icon="device-mobile",
|
||||
route="/admin/prospecting/capture",
|
||||
order=15,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="scan-jobs",
|
||||
label_key="prospecting.menu.scan_jobs",
|
||||
icon="radar",
|
||||
route="/admin/prospecting/scan-jobs",
|
||||
order=20,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="campaigns",
|
||||
label_key="prospecting.menu.campaigns",
|
||||
icon="mail",
|
||||
route="/admin/prospecting/campaigns",
|
||||
order=25,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
migrations_path="migrations",
|
||||
services_path="app.modules.prospecting.services",
|
||||
models_path="app.modules.prospecting.models",
|
||||
schemas_path="app.modules.prospecting.schemas",
|
||||
exceptions_path="app.modules.prospecting.exceptions",
|
||||
templates_path="templates",
|
||||
locales_path="locales",
|
||||
tasks_path="app.modules.prospecting.tasks",
|
||||
)
|
||||
|
||||
|
||||
def get_prospecting_module_with_routers() -> ModuleDefinition:
|
||||
"""
|
||||
Get prospecting module with routers attached.
|
||||
|
||||
Attaches routers lazily to avoid circular imports during module initialization.
|
||||
"""
|
||||
prospecting_module.admin_api_router = _get_admin_api_router()
|
||||
prospecting_module.admin_page_router = _get_admin_page_router()
|
||||
return prospecting_module
|
||||
|
||||
|
||||
__all__ = ["prospecting_module", "get_prospecting_module_with_routers"]
|
||||
171
app/modules/prospecting/docs/database.md
Normal file
171
app/modules/prospecting/docs/database.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Database Schema
|
||||
|
||||
## Entity Relationship Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌────────────────────────┐
|
||||
│ prospects │────<│ prospect_tech_profiles │
|
||||
├─────────────────────┤ ├────────────────────────┤
|
||||
│ id │ │ id │
|
||||
│ channel │ │ prospect_id (FK) │
|
||||
│ business_name │ │ cms, server │
|
||||
│ domain_name │ │ hosting_provider │
|
||||
│ status │ │ js_framework, cdn │
|
||||
│ source │ │ analytics │
|
||||
│ has_website │ │ ecommerce_platform │
|
||||
│ uses_https │ │ tech_stack_json (JSON) │
|
||||
│ ... │ └────────────────────────┘
|
||||
└─────────────────────┘
|
||||
│
|
||||
│ ┌──────────────────────────────┐
|
||||
└──────────────<│ prospect_performance_profiles │
|
||||
│ ├──────────────────────────────┤
|
||||
│ │ id │
|
||||
│ │ prospect_id (FK) │
|
||||
│ │ performance_score (0-100) │
|
||||
│ │ accessibility_score │
|
||||
│ │ seo_score │
|
||||
│ │ FCP, LCP, TBT, CLS │
|
||||
│ │ is_mobile_friendly │
|
||||
│ └──────────────────────────────┘
|
||||
│
|
||||
│ ┌───────────────────────┐
|
||||
└──────────────<│ prospect_scores │
|
||||
│ ├───────────────────────┤
|
||||
│ │ id │
|
||||
│ │ prospect_id (FK) │
|
||||
│ │ score (0-100) │
|
||||
│ │ technical_health_score│
|
||||
│ │ modernity_score │
|
||||
│ │ business_value_score │
|
||||
│ │ engagement_score │
|
||||
│ │ reason_flags (JSON) │
|
||||
│ │ lead_tier │
|
||||
│ └───────────────────────┘
|
||||
│
|
||||
│ ┌───────────────────────┐
|
||||
└──────────────<│ prospect_contacts │
|
||||
│ ├───────────────────────┤
|
||||
│ │ id │
|
||||
│ │ prospect_id (FK) │
|
||||
│ │ contact_type │
|
||||
│ │ value │
|
||||
│ │ source_url │
|
||||
│ │ is_primary │
|
||||
│ └───────────────────────┘
|
||||
│
|
||||
│ ┌───────────────────────┐
|
||||
└──────────────<│ prospect_interactions │
|
||||
│ ├───────────────────────┤
|
||||
│ │ id │
|
||||
│ │ prospect_id (FK) │
|
||||
│ │ interaction_type │
|
||||
│ │ subject, notes │
|
||||
│ │ outcome │
|
||||
│ │ next_action │
|
||||
│ │ next_action_date │
|
||||
│ │ created_by_user_id │
|
||||
│ └───────────────────────┘
|
||||
│
|
||||
│ ┌───────────────────────┐
|
||||
└──────────────<│ prospect_scan_jobs │
|
||||
├───────────────────────┤
|
||||
│ id │
|
||||
│ job_type │
|
||||
│ status │
|
||||
│ total_items │
|
||||
│ processed_items │
|
||||
│ celery_task_id │
|
||||
└───────────────────────┘
|
||||
|
||||
┌──────────────────────┐ ┌──────────────────┐
|
||||
│ campaign_templates │────<│ campaign_sends │
|
||||
├──────────────────────┤ ├──────────────────┤
|
||||
│ id │ │ id │
|
||||
│ name │ │ template_id (FK) │
|
||||
│ lead_type │ │ prospect_id (FK) │
|
||||
│ channel │ │ channel │
|
||||
│ language │ │ rendered_subject │
|
||||
│ subject_template │ │ rendered_body │
|
||||
│ body_template │ │ status │
|
||||
│ is_active │ │ sent_at │
|
||||
└──────────────────────┘ │ sent_by_user_id │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## Tables
|
||||
|
||||
### prospects
|
||||
|
||||
Central table for all leads — both digital (domain-based) and offline (in-person).
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | INTEGER PK | Auto-increment |
|
||||
| channel | ENUM(digital, offline) | How the lead was discovered |
|
||||
| business_name | VARCHAR(255) | Required for offline |
|
||||
| domain_name | VARCHAR(255) | Required for digital, unique |
|
||||
| status | ENUM | pending, active, inactive, parked, error, contacted, converted |
|
||||
| source | VARCHAR(100) | e.g. "domain_scan", "networking_event", "street" |
|
||||
| has_website | BOOLEAN | Determined by HTTP check |
|
||||
| uses_https | BOOLEAN | SSL status |
|
||||
| http_status_code | INTEGER | Last HTTP response |
|
||||
| address | VARCHAR(500) | Physical address (offline) |
|
||||
| city | VARCHAR(100) | City |
|
||||
| postal_code | VARCHAR(10) | Postal code |
|
||||
| country | VARCHAR(2) | Default "LU" |
|
||||
| notes | TEXT | Free-form notes |
|
||||
| tags | JSON | Flexible tagging |
|
||||
| captured_by_user_id | INTEGER FK | Who captured this lead |
|
||||
| location_lat / location_lng | FLOAT | GPS from mobile capture |
|
||||
| last_*_at | DATETIME | Timestamps for each scan type |
|
||||
|
||||
### prospect_tech_profiles
|
||||
|
||||
Technology stack detection results. One per prospect.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| cms | VARCHAR(100) | WordPress, Drupal, Joomla, etc. |
|
||||
| server | VARCHAR(100) | Nginx, Apache |
|
||||
| hosting_provider | VARCHAR(100) | Hosting company |
|
||||
| cdn | VARCHAR(100) | CDN provider |
|
||||
| js_framework | VARCHAR(100) | React, Vue, Angular, jQuery |
|
||||
| analytics | VARCHAR(200) | Google Analytics, Matomo, etc. |
|
||||
| ecommerce_platform | VARCHAR(100) | Shopify, WooCommerce, etc. |
|
||||
| tech_stack_json | JSON | Full detection results |
|
||||
|
||||
### prospect_performance_profiles
|
||||
|
||||
Lighthouse audit results. One per prospect.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| performance_score | INTEGER | 0-100 |
|
||||
| accessibility_score | INTEGER | 0-100 |
|
||||
| seo_score | INTEGER | 0-100 |
|
||||
| first_contentful_paint_ms | INTEGER | FCP |
|
||||
| largest_contentful_paint_ms | INTEGER | LCP |
|
||||
| total_blocking_time_ms | INTEGER | TBT |
|
||||
| cumulative_layout_shift | FLOAT | CLS |
|
||||
| is_mobile_friendly | BOOLEAN | Mobile test |
|
||||
|
||||
### prospect_scores
|
||||
|
||||
Calculated opportunity scores. One per prospect. See [scoring.md](scoring.md) for algorithm details.
|
||||
|
||||
### prospect_contacts
|
||||
|
||||
Scraped or manually entered contact info. Many per prospect.
|
||||
|
||||
### prospect_interactions
|
||||
|
||||
CRM-style interaction log. Many per prospect. Types: note, call, email_sent, email_received, meeting, visit, sms, proposal_sent.
|
||||
|
||||
### prospect_scan_jobs
|
||||
|
||||
Background job tracking for batch operations.
|
||||
|
||||
### campaign_templates / campaign_sends
|
||||
|
||||
Marketing campaign templates and send tracking. Templates support placeholders like `{business_name}`, `{domain}`, `{score}`, `{issues}`.
|
||||
80
app/modules/prospecting/docs/research-findings.md
Normal file
80
app/modules/prospecting/docs/research-findings.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# .lu Domain Lead Generation — Research Findings
|
||||
|
||||
Research on data sources, APIs, legal requirements, and cost analysis for the prospecting module.
|
||||
|
||||
## 1. Data Sources for .lu Domains
|
||||
|
||||
The official .lu registry (DNS-LU / RESTENA) does **not** publish zone files. All providers use web crawling to discover domains, so no list is 100% complete. Expect 70-80% coverage.
|
||||
|
||||
### Providers
|
||||
|
||||
| Provider | Domains | Price | Format | Notes |
|
||||
|----------|---------|-------|--------|-------|
|
||||
| NetworksDB | ~70,000 | $5 | Zipped text | Best value, one-time purchase |
|
||||
| DomainMetaData | Varies | $9.90/mo | CSV | Daily updates |
|
||||
| Webatla | ~75,000 | Unknown | CSV | Good coverage |
|
||||
|
||||
## 2. Technical APIs — Cost Analysis
|
||||
|
||||
### Technology Detection
|
||||
|
||||
| Service | Free Tier | Notes |
|
||||
|---------|-----------|-------|
|
||||
| CRFT Lookup | Unlimited | Budget option, includes Lighthouse |
|
||||
| Wappalyzer | 50/month | Most accurate |
|
||||
| WhatCMS | Free lookups | CMS-only |
|
||||
|
||||
**Approach used**: Custom HTML parsing for CMS, JS framework, analytics, and server detection (no external API dependency).
|
||||
|
||||
### Performance Audits
|
||||
|
||||
PageSpeed Insights API — **free**, 25,000 queries/day, 400/100 seconds.
|
||||
|
||||
### SSL Checks
|
||||
|
||||
Simple HTTPS connectivity check (fast). SSL Labs API available for deep analysis of high-priority leads.
|
||||
|
||||
### WHOIS
|
||||
|
||||
Due to GDPR, .lu WHOIS data for private individuals is hidden. Only owner type and country visible. Contact info scraped from websites instead.
|
||||
|
||||
## 3. Legal — Luxembourg & GDPR
|
||||
|
||||
### B2B Cold Email Rules
|
||||
|
||||
Luxembourg has **no specific B2B cold email restrictions** per Article 11(1) of the Electronic Privacy Act (applies only to natural persons).
|
||||
|
||||
**Requirements**:
|
||||
1. Identify yourself clearly (company name, address)
|
||||
2. Provide opt-out mechanism in every email
|
||||
3. Message must relate to recipient's business
|
||||
4. Store contact data securely
|
||||
5. Only contact businesses, not private individuals
|
||||
|
||||
**Legal basis**: Legitimate interest (GDPR Art. 6(1)(f))
|
||||
|
||||
### GDPR Penalties
|
||||
|
||||
Fines up to EUR 20 million or 4% of global revenue for violations.
|
||||
|
||||
**Key violations to avoid**:
|
||||
- Emailing private individuals without consent
|
||||
- No opt-out mechanism
|
||||
- Holding personal data longer than necessary
|
||||
|
||||
### Recommendation
|
||||
|
||||
- Focus on `info@`, `contact@`, and business role emails
|
||||
- Always include unsubscribe link
|
||||
- Document legitimate interest basis
|
||||
|
||||
## 4. Cost Summary
|
||||
|
||||
| Item | Cost | Type |
|
||||
|------|------|------|
|
||||
| Domain list (NetworksDB) | $5 | One-time |
|
||||
| PageSpeed API | Free | Ongoing |
|
||||
| Contact scraping | Free | Self-hosted |
|
||||
| Tech detection | Free | Self-hosted |
|
||||
|
||||
Working MVP costs under $25 total.
|
||||
110
app/modules/prospecting/docs/scoring.md
Normal file
110
app/modules/prospecting/docs/scoring.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Opportunity Scoring Model
|
||||
|
||||
## Overview
|
||||
|
||||
The scoring model assigns each prospect a score from 0-100 based on the opportunity potential for offering web services. Higher scores indicate better leads. The model supports two channels: **digital** (domain-based) and **offline** (in-person discovery).
|
||||
|
||||
## Score Components — Digital Channel
|
||||
|
||||
### Technical Health (Max 40 points)
|
||||
|
||||
Issues that indicate immediate opportunities:
|
||||
|
||||
| Issue | Points | Condition |
|
||||
|-------|--------|-----------|
|
||||
| No SSL | 15 | `uses_https = false` |
|
||||
| Very Slow | 15 | `performance_score < 30` |
|
||||
| Slow | 10 | `performance_score < 50` |
|
||||
| Moderate Speed | 5 | `performance_score < 70` |
|
||||
| Not Mobile Friendly | 10 | `is_mobile_friendly = false` |
|
||||
|
||||
### Modernity / Stack (Max 25 points)
|
||||
|
||||
Outdated technology stack:
|
||||
|
||||
| Issue | Points | Condition |
|
||||
|-------|--------|-----------|
|
||||
| Outdated CMS | 15 | CMS is Drupal, Joomla, or TYPO3 |
|
||||
| Unknown CMS | 5 | No CMS detected but has website |
|
||||
| Legacy JavaScript | 5 | Uses jQuery without modern framework |
|
||||
| No Analytics | 5 | No Google Analytics or similar |
|
||||
|
||||
### Business Value (Max 25 points)
|
||||
|
||||
Indicators of business potential:
|
||||
|
||||
| Factor | Points | Condition |
|
||||
|--------|--------|-----------|
|
||||
| Has Website | 10 | Active website exists |
|
||||
| Has E-commerce | 10 | E-commerce platform detected |
|
||||
| Short Domain | 5 | Domain name <= 15 characters |
|
||||
|
||||
### Engagement Potential (Max 10 points)
|
||||
|
||||
Ability to contact the business:
|
||||
|
||||
| Factor | Points | Condition |
|
||||
|--------|--------|-----------|
|
||||
| Has Contacts | 5 | Any contact info found |
|
||||
| Has Email | 3 | Email address found |
|
||||
| Has Phone | 2 | Phone number found |
|
||||
|
||||
## Score Components — Offline Channel
|
||||
|
||||
Offline leads have a simplified scoring model based on the information captured during in-person encounters:
|
||||
|
||||
| Scenario | Technical Health | Modernity | Business Value | Engagement | Total |
|
||||
|----------|-----------------|-----------|----------------|------------|-------|
|
||||
| No website at all | 30 | 20 | 20 | 0 | **70** (top_priority) |
|
||||
| Uses gmail/free email | +0 | +10 | +0 | +0 | +10 |
|
||||
| Met in person | +0 | +0 | +0 | +5 | +5 |
|
||||
| Has email contact | +0 | +0 | +0 | +3 | +3 |
|
||||
| Has phone contact | +0 | +0 | +0 | +2 | +2 |
|
||||
|
||||
A business with no website met in person with contact info scores: 70 + 5 + 3 + 2 = **80** (top_priority).
|
||||
|
||||
## Lead Tiers
|
||||
|
||||
Based on the total score:
|
||||
|
||||
| Tier | Score Range | Description |
|
||||
|------|-------------|-------------|
|
||||
| `top_priority` | 70-100 | Best leads, multiple issues or no website at all |
|
||||
| `quick_win` | 50-69 | Good leads, 1-2 easy fixes |
|
||||
| `strategic` | 30-49 | Moderate potential |
|
||||
| `low_priority` | 0-29 | Low opportunity |
|
||||
|
||||
## Reason Flags
|
||||
|
||||
Each score includes `reason_flags` that explain why points were awarded:
|
||||
|
||||
```json
|
||||
{
|
||||
"score": 78,
|
||||
"reason_flags": ["no_ssl", "slow", "outdated_cms"],
|
||||
"lead_tier": "top_priority"
|
||||
}
|
||||
```
|
||||
|
||||
Common flags (digital):
|
||||
- `no_ssl` — Missing HTTPS
|
||||
- `very_slow` — Performance score < 30
|
||||
- `slow` — Performance score < 50
|
||||
- `not_mobile_friendly` — Fails mobile tests
|
||||
- `outdated_cms` — Using old CMS
|
||||
- `legacy_js` — Using jQuery only
|
||||
- `no_analytics` — No tracking installed
|
||||
|
||||
Offline-specific flags:
|
||||
- `no_website` — Business has no website
|
||||
- `uses_gmail` — Uses free email provider
|
||||
- `met_in_person` — Lead captured in person (warm lead)
|
||||
|
||||
## Customizing the Model
|
||||
|
||||
The scoring logic is in `app/modules/prospecting/services/scoring_service.py`. You can adjust:
|
||||
|
||||
1. **Point values** — Change weights for different issues
|
||||
2. **Thresholds** — Adjust performance score cutoffs
|
||||
3. **Conditions** — Add new scoring criteria
|
||||
4. **Tier boundaries** — Change score ranges for tiers
|
||||
80
app/modules/prospecting/exceptions.py
Normal file
80
app/modules/prospecting/exceptions.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# app/modules/prospecting/exceptions.py
|
||||
"""
|
||||
Prospecting module exceptions.
|
||||
"""
|
||||
|
||||
from app.exceptions.base import (
|
||||
BusinessLogicException,
|
||||
ExternalServiceException,
|
||||
ResourceNotFoundException,
|
||||
)
|
||||
|
||||
|
||||
class ProspectNotFoundException(ResourceNotFoundException): # noqa: MOD025
|
||||
"""Raised when a prospect is not found."""
|
||||
|
||||
def __init__(self, identifier: str):
|
||||
super().__init__(
|
||||
resource_type="Prospect",
|
||||
identifier=identifier,
|
||||
)
|
||||
|
||||
|
||||
class ScanJobNotFoundException(ResourceNotFoundException): # noqa: MOD025
|
||||
"""Raised when a scan job is not found."""
|
||||
|
||||
def __init__(self, identifier: str):
|
||||
super().__init__(
|
||||
resource_type="ScanJob",
|
||||
identifier=identifier,
|
||||
)
|
||||
|
||||
|
||||
class CampaignTemplateNotFoundException(ResourceNotFoundException): # noqa: MOD025
|
||||
"""Raised when a campaign template is not found."""
|
||||
|
||||
def __init__(self, identifier: str):
|
||||
super().__init__(
|
||||
resource_type="CampaignTemplate",
|
||||
identifier=identifier,
|
||||
)
|
||||
|
||||
|
||||
class DuplicateDomainException(BusinessLogicException): # noqa: MOD025
|
||||
"""Raised when trying to create a prospect with a domain that already exists."""
|
||||
|
||||
def __init__(self, domain_name: str):
|
||||
super().__init__(
|
||||
message=f"A prospect with domain '{domain_name}' already exists",
|
||||
error_code="DUPLICATE_DOMAIN",
|
||||
)
|
||||
|
||||
|
||||
class ScanFailedException(ExternalServiceException): # noqa: MOD025
|
||||
"""Raised when an enrichment scan fails."""
|
||||
|
||||
def __init__(self, scan_type: str, domain: str, reason: str):
|
||||
super().__init__(
|
||||
message=f"{scan_type} scan failed for {domain}: {reason}",
|
||||
service_name=f"prospecting_{scan_type}",
|
||||
)
|
||||
|
||||
|
||||
class CampaignRenderException(BusinessLogicException): # noqa: MOD025
|
||||
"""Raised when campaign template rendering fails."""
|
||||
|
||||
def __init__(self, template_id: int, reason: str):
|
||||
super().__init__(
|
||||
message=f"Failed to render campaign template {template_id}: {reason}",
|
||||
error_code="CAMPAIGN_RENDER_FAILED",
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ProspectNotFoundException",
|
||||
"ScanJobNotFoundException",
|
||||
"CampaignTemplateNotFoundException",
|
||||
"DuplicateDomainException",
|
||||
"ScanFailedException",
|
||||
"CampaignRenderException",
|
||||
]
|
||||
34
app/modules/prospecting/locales/de.json
Normal file
34
app/modules/prospecting/locales/de.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"prospecting": {
|
||||
"page_title": "Akquise",
|
||||
"dashboard_title": "Akquise Dashboard",
|
||||
"prospects": "Interessenten",
|
||||
"leads": "Leads",
|
||||
"capture": "Schnellerfassung",
|
||||
"scan_jobs": "Scan-Aufträge",
|
||||
"campaigns": "Kampagnen",
|
||||
"loading": "Laden...",
|
||||
"error_loading": "Fehler beim Laden der Daten"
|
||||
},
|
||||
"permissions": {
|
||||
"view": "Interessenten anzeigen",
|
||||
"view_desc": "Zugriff auf Interessenten, Leads und Statistiken",
|
||||
"manage": "Interessenten verwalten",
|
||||
"manage_desc": "Interessenten erstellen, bearbeiten und löschen",
|
||||
"scan": "Scans ausführen",
|
||||
"scan_desc": "Domain-Scan und Anreicherungsoperationen durchführen",
|
||||
"campaigns": "Kampagnen verwalten",
|
||||
"campaigns_desc": "Marketingkampagnen erstellen und versenden",
|
||||
"export": "Daten exportieren",
|
||||
"export_desc": "Leads als CSV exportieren"
|
||||
},
|
||||
"menu": {
|
||||
"prospecting": "Akquise",
|
||||
"dashboard": "Dashboard",
|
||||
"prospects": "Interessenten",
|
||||
"leads": "Leads",
|
||||
"capture": "Schnellerfassung",
|
||||
"scan_jobs": "Scan-Aufträge",
|
||||
"campaigns": "Kampagnen"
|
||||
}
|
||||
}
|
||||
34
app/modules/prospecting/locales/en.json
Normal file
34
app/modules/prospecting/locales/en.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"prospecting": {
|
||||
"page_title": "Prospecting",
|
||||
"dashboard_title": "Prospecting Dashboard",
|
||||
"prospects": "Prospects",
|
||||
"leads": "Leads",
|
||||
"capture": "Quick Capture",
|
||||
"scan_jobs": "Scan Jobs",
|
||||
"campaigns": "Campaigns",
|
||||
"loading": "Loading...",
|
||||
"error_loading": "Failed to load data"
|
||||
},
|
||||
"permissions": {
|
||||
"view": "View Prospects",
|
||||
"view_desc": "View prospect data, leads, and statistics",
|
||||
"manage": "Manage Prospects",
|
||||
"manage_desc": "Create, edit, and delete prospects",
|
||||
"scan": "Run Scans",
|
||||
"scan_desc": "Execute domain scanning and enrichment operations",
|
||||
"campaigns": "Manage Campaigns",
|
||||
"campaigns_desc": "Create and send marketing campaigns",
|
||||
"export": "Export Data",
|
||||
"export_desc": "Export leads to CSV"
|
||||
},
|
||||
"menu": {
|
||||
"prospecting": "Prospecting",
|
||||
"dashboard": "Dashboard",
|
||||
"prospects": "Prospects",
|
||||
"leads": "Leads",
|
||||
"capture": "Quick Capture",
|
||||
"scan_jobs": "Scan Jobs",
|
||||
"campaigns": "Campaigns"
|
||||
}
|
||||
}
|
||||
34
app/modules/prospecting/locales/fr.json
Normal file
34
app/modules/prospecting/locales/fr.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"prospecting": {
|
||||
"page_title": "Prospection",
|
||||
"dashboard_title": "Tableau de bord Prospection",
|
||||
"prospects": "Prospects",
|
||||
"leads": "Leads",
|
||||
"capture": "Capture rapide",
|
||||
"scan_jobs": "Tâches de scan",
|
||||
"campaigns": "Campagnes",
|
||||
"loading": "Chargement...",
|
||||
"error_loading": "Erreur lors du chargement des données"
|
||||
},
|
||||
"permissions": {
|
||||
"view": "Voir les prospects",
|
||||
"view_desc": "Voir les données des prospects, leads et statistiques",
|
||||
"manage": "Gérer les prospects",
|
||||
"manage_desc": "Créer, modifier et supprimer des prospects",
|
||||
"scan": "Lancer les scans",
|
||||
"scan_desc": "Exécuter les opérations de scan et d'enrichissement des domaines",
|
||||
"campaigns": "Gérer les campagnes",
|
||||
"campaigns_desc": "Créer et envoyer des campagnes marketing",
|
||||
"export": "Exporter les données",
|
||||
"export_desc": "Exporter les leads en CSV"
|
||||
},
|
||||
"menu": {
|
||||
"prospecting": "Prospection",
|
||||
"dashboard": "Tableau de bord",
|
||||
"prospects": "Prospects",
|
||||
"leads": "Leads",
|
||||
"capture": "Capture rapide",
|
||||
"scan_jobs": "Tâches de scan",
|
||||
"campaigns": "Campagnes"
|
||||
}
|
||||
}
|
||||
34
app/modules/prospecting/locales/lb.json
Normal file
34
app/modules/prospecting/locales/lb.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"prospecting": {
|
||||
"page_title": "Prospektioun",
|
||||
"dashboard_title": "Prospektioun Dashboard",
|
||||
"prospects": "Interessenten",
|
||||
"leads": "Leads",
|
||||
"capture": "Schnell Erfaassung",
|
||||
"scan_jobs": "Scan Aufträg",
|
||||
"campaigns": "Kampagnen",
|
||||
"loading": "Lueden...",
|
||||
"error_loading": "Feeler beim Luede vun den Donnéeën"
|
||||
},
|
||||
"permissions": {
|
||||
"view": "Interessente kucken",
|
||||
"view_desc": "Zougang zu Interessenten, Leads an Statistiken",
|
||||
"manage": "Interessente geréieren",
|
||||
"manage_desc": "Interessenten erstellen, änneren a läschen",
|
||||
"scan": "Scanne lafen",
|
||||
"scan_desc": "Domain-Scan an Enrichment Operatiounen duerchféieren",
|
||||
"campaigns": "Kampagne geréieren",
|
||||
"campaigns_desc": "Marketing Kampagnen erstellen a schécken",
|
||||
"export": "Daten exportéieren",
|
||||
"export_desc": "Leads als CSV exportéieren"
|
||||
},
|
||||
"menu": {
|
||||
"prospecting": "Prospektioun",
|
||||
"dashboard": "Dashboard",
|
||||
"prospects": "Interessenten",
|
||||
"leads": "Leads",
|
||||
"capture": "Schnell Erfaassung",
|
||||
"scan_jobs": "Scan Aufträg",
|
||||
"campaigns": "Kampagnen"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"""prospecting: initial tables for lead discovery and campaign management
|
||||
|
||||
Revision ID: prospecting_001
|
||||
Revises: None
|
||||
Create Date: 2026-02-27
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "prospecting_001"
|
||||
down_revision = None
|
||||
branch_labels = ("prospecting",)
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --- prospects ---
|
||||
op.create_table(
|
||||
"prospects",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("channel", sa.String(10), nullable=False, server_default="digital"),
|
||||
sa.Column("business_name", sa.String(255), nullable=True),
|
||||
sa.Column("domain_name", sa.String(255), nullable=True, unique=True, index=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("source", sa.String(100), nullable=True),
|
||||
sa.Column("has_website", sa.Boolean(), nullable=True),
|
||||
sa.Column("uses_https", sa.Boolean(), nullable=True),
|
||||
sa.Column("http_status_code", sa.Integer(), nullable=True),
|
||||
sa.Column("redirect_url", sa.Text(), nullable=True),
|
||||
sa.Column("address", sa.String(500), nullable=True),
|
||||
sa.Column("city", sa.String(100), nullable=True),
|
||||
sa.Column("postal_code", sa.String(10), nullable=True),
|
||||
sa.Column("country", sa.String(2), nullable=False, server_default="LU"),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("tags", sa.Text(), nullable=True),
|
||||
sa.Column("captured_by_user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("location_lat", sa.Float(), nullable=True),
|
||||
sa.Column("location_lng", sa.Float(), nullable=True),
|
||||
sa.Column("last_http_check_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_tech_scan_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_perf_scan_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_contact_scrape_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# --- prospect_tech_profiles ---
|
||||
op.create_table(
|
||||
"prospect_tech_profiles",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("prospect_id", sa.Integer(), sa.ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, unique=True),
|
||||
sa.Column("cms", sa.String(100), nullable=True),
|
||||
sa.Column("cms_version", sa.String(50), nullable=True),
|
||||
sa.Column("server", sa.String(100), nullable=True),
|
||||
sa.Column("server_version", sa.String(50), nullable=True),
|
||||
sa.Column("hosting_provider", sa.String(100), nullable=True),
|
||||
sa.Column("cdn", sa.String(100), nullable=True),
|
||||
sa.Column("has_valid_cert", sa.Boolean(), nullable=True),
|
||||
sa.Column("cert_issuer", sa.String(200), nullable=True),
|
||||
sa.Column("cert_expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("js_framework", sa.String(100), nullable=True),
|
||||
sa.Column("analytics", sa.String(200), nullable=True),
|
||||
sa.Column("tag_manager", sa.String(100), nullable=True),
|
||||
sa.Column("ecommerce_platform", sa.String(100), nullable=True),
|
||||
sa.Column("tech_stack_json", sa.Text(), nullable=True),
|
||||
sa.Column("scan_source", sa.String(50), nullable=True),
|
||||
sa.Column("scan_error", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# --- prospect_performance_profiles ---
|
||||
op.create_table(
|
||||
"prospect_performance_profiles",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("prospect_id", sa.Integer(), sa.ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, unique=True),
|
||||
sa.Column("performance_score", sa.Integer(), nullable=True),
|
||||
sa.Column("accessibility_score", sa.Integer(), nullable=True),
|
||||
sa.Column("best_practices_score", sa.Integer(), nullable=True),
|
||||
sa.Column("seo_score", sa.Integer(), nullable=True),
|
||||
sa.Column("first_contentful_paint_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("largest_contentful_paint_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("total_blocking_time_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("cumulative_layout_shift", sa.Float(), nullable=True),
|
||||
sa.Column("speed_index", sa.Integer(), nullable=True),
|
||||
sa.Column("time_to_interactive_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("is_mobile_friendly", sa.Boolean(), nullable=True),
|
||||
sa.Column("viewport_configured", sa.Boolean(), nullable=True),
|
||||
sa.Column("font_size_ok", sa.Boolean(), nullable=True),
|
||||
sa.Column("tap_targets_ok", sa.Boolean(), nullable=True),
|
||||
sa.Column("total_bytes", sa.Integer(), nullable=True),
|
||||
sa.Column("html_bytes", sa.Integer(), nullable=True),
|
||||
sa.Column("css_bytes", sa.Integer(), nullable=True),
|
||||
sa.Column("js_bytes", sa.Integer(), nullable=True),
|
||||
sa.Column("image_bytes", sa.Integer(), nullable=True),
|
||||
sa.Column("font_bytes", sa.Integer(), nullable=True),
|
||||
sa.Column("total_requests", sa.Integer(), nullable=True),
|
||||
sa.Column("js_requests", sa.Integer(), nullable=True),
|
||||
sa.Column("css_requests", sa.Integer(), nullable=True),
|
||||
sa.Column("image_requests", sa.Integer(), nullable=True),
|
||||
sa.Column("lighthouse_json", sa.Text(), nullable=True),
|
||||
sa.Column("scan_strategy", sa.String(20), nullable=True),
|
||||
sa.Column("scan_error", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# --- prospect_scores ---
|
||||
op.create_table(
|
||||
"prospect_scores",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("prospect_id", sa.Integer(), sa.ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, unique=True),
|
||||
sa.Column("score", sa.Integer(), nullable=False, server_default="0", index=True),
|
||||
sa.Column("technical_health_score", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("modernity_score", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("business_value_score", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("engagement_score", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("reason_flags", sa.Text(), nullable=True),
|
||||
sa.Column("score_breakdown", sa.Text(), nullable=True),
|
||||
sa.Column("lead_tier", sa.String(20), nullable=True, index=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# --- prospect_contacts ---
|
||||
op.create_table(
|
||||
"prospect_contacts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("prospect_id", sa.Integer(), sa.ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("contact_type", sa.String(20), nullable=False),
|
||||
sa.Column("value", sa.String(500), nullable=False),
|
||||
sa.Column("label", sa.String(100), nullable=True),
|
||||
sa.Column("source_url", sa.Text(), nullable=True),
|
||||
sa.Column("source_element", sa.String(100), nullable=True),
|
||||
sa.Column("is_validated", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("validation_error", sa.Text(), nullable=True),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# --- prospect_scan_jobs ---
|
||||
op.create_table(
|
||||
"prospect_scan_jobs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("job_type", sa.String(30), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("total_items", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("processed_items", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("failed_items", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("skipped_items", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("config", sa.Text(), nullable=True),
|
||||
sa.Column("result_summary", sa.Text(), nullable=True),
|
||||
sa.Column("error_log", sa.Text(), nullable=True),
|
||||
sa.Column("source_file", sa.String(500), nullable=True),
|
||||
sa.Column("celery_task_id", sa.String(255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# --- prospect_interactions ---
|
||||
op.create_table(
|
||||
"prospect_interactions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("prospect_id", sa.Integer(), sa.ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("interaction_type", sa.String(20), nullable=False),
|
||||
sa.Column("subject", sa.String(255), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("outcome", sa.String(20), nullable=True),
|
||||
sa.Column("next_action", sa.String(255), nullable=True),
|
||||
sa.Column("next_action_date", sa.Date(), nullable=True),
|
||||
sa.Column("created_by_user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# --- campaign_templates ---
|
||||
op.create_table(
|
||||
"campaign_templates",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("lead_type", sa.String(30), nullable=False),
|
||||
sa.Column("channel", sa.String(20), nullable=False, server_default="email"),
|
||||
sa.Column("language", sa.String(5), nullable=False, server_default="fr"),
|
||||
sa.Column("subject_template", sa.String(500), nullable=True),
|
||||
sa.Column("body_template", sa.Text(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
# --- campaign_sends ---
|
||||
op.create_table(
|
||||
"campaign_sends",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("template_id", sa.Integer(), sa.ForeignKey("campaign_templates.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("prospect_id", sa.Integer(), sa.ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("channel", sa.String(20), nullable=False),
|
||||
sa.Column("rendered_subject", sa.String(500), nullable=True),
|
||||
sa.Column("rendered_body", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="draft"),
|
||||
sa.Column("sent_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("sent_by_user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("campaign_sends")
|
||||
op.drop_table("campaign_templates")
|
||||
op.drop_table("prospect_interactions")
|
||||
op.drop_table("prospect_scan_jobs")
|
||||
op.drop_table("prospect_contacts")
|
||||
op.drop_table("prospect_scores")
|
||||
op.drop_table("prospect_performance_profiles")
|
||||
op.drop_table("prospect_tech_profiles")
|
||||
op.drop_table("prospects")
|
||||
47
app/modules/prospecting/models/__init__.py
Normal file
47
app/modules/prospecting/models/__init__.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# app/modules/prospecting/models/__init__.py
|
||||
from app.modules.prospecting.models.campaign import (
|
||||
CampaignChannel,
|
||||
CampaignSend,
|
||||
CampaignSendStatus,
|
||||
CampaignTemplate,
|
||||
LeadType,
|
||||
)
|
||||
from app.modules.prospecting.models.interaction import (
|
||||
InteractionOutcome,
|
||||
InteractionType,
|
||||
ProspectInteraction,
|
||||
)
|
||||
from app.modules.prospecting.models.performance_profile import (
|
||||
ProspectPerformanceProfile,
|
||||
)
|
||||
from app.modules.prospecting.models.prospect import (
|
||||
Prospect,
|
||||
ProspectChannel,
|
||||
ProspectStatus,
|
||||
)
|
||||
from app.modules.prospecting.models.prospect_contact import ContactType, ProspectContact
|
||||
from app.modules.prospecting.models.prospect_score import ProspectScore
|
||||
from app.modules.prospecting.models.scan_job import JobStatus, JobType, ProspectScanJob
|
||||
from app.modules.prospecting.models.tech_profile import ProspectTechProfile
|
||||
|
||||
__all__ = [
|
||||
"Prospect",
|
||||
"ProspectChannel",
|
||||
"ProspectStatus",
|
||||
"ProspectTechProfile",
|
||||
"ProspectPerformanceProfile",
|
||||
"ProspectScore",
|
||||
"ProspectContact",
|
||||
"ContactType",
|
||||
"ProspectScanJob",
|
||||
"JobType",
|
||||
"JobStatus",
|
||||
"ProspectInteraction",
|
||||
"InteractionType",
|
||||
"InteractionOutcome",
|
||||
"CampaignTemplate",
|
||||
"CampaignSend",
|
||||
"CampaignChannel",
|
||||
"CampaignSendStatus",
|
||||
"LeadType",
|
||||
]
|
||||
83
app/modules/prospecting/models/campaign.py
Normal file
83
app/modules/prospecting/models/campaign.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# app/modules/prospecting/models/campaign.py
|
||||
"""
|
||||
Campaign templates and send tracking.
|
||||
|
||||
Templates are tailored by lead type (no_website, bad_website, etc.)
|
||||
with support for multiple languages and delivery channels.
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class LeadType(str, enum.Enum):
|
||||
NO_WEBSITE = "no_website"
|
||||
BAD_WEBSITE = "bad_website"
|
||||
GMAIL_ONLY = "gmail_only"
|
||||
SECURITY_ISSUES = "security_issues"
|
||||
PERFORMANCE_ISSUES = "performance_issues"
|
||||
OUTDATED_CMS = "outdated_cms"
|
||||
GENERAL = "general"
|
||||
|
||||
|
||||
class CampaignChannel(str, enum.Enum):
|
||||
EMAIL = "email"
|
||||
LETTER = "letter"
|
||||
PHONE_SCRIPT = "phone_script"
|
||||
|
||||
|
||||
class CampaignSendStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
SENT = "sent"
|
||||
DELIVERED = "delivered"
|
||||
OPENED = "opened"
|
||||
BOUNCED = "bounced"
|
||||
REPLIED = "replied"
|
||||
|
||||
|
||||
class CampaignTemplate(Base, TimestampMixin):
|
||||
"""A reusable marketing campaign template."""
|
||||
|
||||
__tablename__ = "campaign_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
lead_type = Column(Enum(LeadType), nullable=False)
|
||||
channel = Column(Enum(CampaignChannel), nullable=False, default=CampaignChannel.EMAIL)
|
||||
language = Column(String(5), nullable=False, default="fr")
|
||||
|
||||
subject_template = Column(String(500), nullable=True)
|
||||
body_template = Column(Text, nullable=False)
|
||||
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
|
||||
class CampaignSend(Base, TimestampMixin):
|
||||
"""A record of a campaign sent to a specific prospect."""
|
||||
|
||||
__tablename__ = "campaign_sends"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
template_id = Column(Integer, ForeignKey("campaign_templates.id", ondelete="SET NULL"), nullable=True)
|
||||
prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
channel = Column(Enum(CampaignChannel), nullable=False)
|
||||
rendered_subject = Column(String(500), nullable=True)
|
||||
rendered_body = Column(Text, nullable=True)
|
||||
|
||||
status = Column(Enum(CampaignSendStatus), nullable=False, default=CampaignSendStatus.DRAFT)
|
||||
sent_at = Column(DateTime, nullable=True)
|
||||
sent_by_user_id = Column(Integer, nullable=True)
|
||||
54
app/modules/prospecting/models/interaction.py
Normal file
54
app/modules/prospecting/models/interaction.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# app/modules/prospecting/models/interaction.py
|
||||
"""
|
||||
Interaction tracking for prospect follow-ups.
|
||||
|
||||
Logs all touchpoints: calls, emails, meetings, visits, notes.
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Column, Date, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class InteractionType(str, enum.Enum):
|
||||
NOTE = "note"
|
||||
CALL = "call"
|
||||
EMAIL_SENT = "email_sent"
|
||||
EMAIL_RECEIVED = "email_received"
|
||||
MEETING = "meeting"
|
||||
VISIT = "visit"
|
||||
SMS = "sms"
|
||||
PROPOSAL_SENT = "proposal_sent"
|
||||
|
||||
|
||||
class InteractionOutcome(str, enum.Enum):
|
||||
POSITIVE = "positive"
|
||||
NEUTRAL = "neutral"
|
||||
NEGATIVE = "negative"
|
||||
NO_ANSWER = "no_answer"
|
||||
|
||||
|
||||
class ProspectInteraction(Base, TimestampMixin):
|
||||
"""A logged interaction with a prospect."""
|
||||
|
||||
__tablename__ = "prospect_interactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
interaction_type = Column(Enum(InteractionType), nullable=False)
|
||||
subject = Column(String(255), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
outcome = Column(Enum(InteractionOutcome), nullable=True)
|
||||
|
||||
next_action = Column(String(255), nullable=True)
|
||||
next_action_date = Column(Date, nullable=True)
|
||||
|
||||
created_by_user_id = Column(Integer, nullable=False)
|
||||
|
||||
# Relationships
|
||||
prospect = relationship("Prospect", back_populates="interactions")
|
||||
64
app/modules/prospecting/models/performance_profile.py
Normal file
64
app/modules/prospecting/models/performance_profile.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# app/modules/prospecting/models/performance_profile.py
|
||||
"""
|
||||
Performance profile for a prospect's website.
|
||||
|
||||
Stores Lighthouse audit results including Core Web Vitals,
|
||||
mobile-friendliness, and asset size analysis.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class ProspectPerformanceProfile(Base, TimestampMixin):
|
||||
"""Performance audit results from PageSpeed Insights / Lighthouse."""
|
||||
|
||||
__tablename__ = "prospect_performance_profiles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, unique=True)
|
||||
|
||||
# Lighthouse Scores (0-100)
|
||||
performance_score = Column(Integer, nullable=True)
|
||||
accessibility_score = Column(Integer, nullable=True)
|
||||
best_practices_score = Column(Integer, nullable=True)
|
||||
seo_score = Column(Integer, nullable=True)
|
||||
|
||||
# Core Web Vitals
|
||||
first_contentful_paint_ms = Column(Integer, nullable=True)
|
||||
largest_contentful_paint_ms = Column(Integer, nullable=True)
|
||||
total_blocking_time_ms = Column(Integer, nullable=True)
|
||||
cumulative_layout_shift = Column(Float, nullable=True)
|
||||
speed_index = Column(Integer, nullable=True)
|
||||
time_to_interactive_ms = Column(Integer, nullable=True)
|
||||
|
||||
# Mobile
|
||||
is_mobile_friendly = Column(Boolean, nullable=True)
|
||||
viewport_configured = Column(Boolean, nullable=True)
|
||||
font_size_ok = Column(Boolean, nullable=True)
|
||||
tap_targets_ok = Column(Boolean, nullable=True)
|
||||
|
||||
# Asset Sizes (bytes)
|
||||
total_bytes = Column(Integer, nullable=True)
|
||||
html_bytes = Column(Integer, nullable=True)
|
||||
css_bytes = Column(Integer, nullable=True)
|
||||
js_bytes = Column(Integer, nullable=True)
|
||||
image_bytes = Column(Integer, nullable=True)
|
||||
font_bytes = Column(Integer, nullable=True)
|
||||
|
||||
# Request Counts
|
||||
total_requests = Column(Integer, nullable=True)
|
||||
js_requests = Column(Integer, nullable=True)
|
||||
css_requests = Column(Integer, nullable=True)
|
||||
image_requests = Column(Integer, nullable=True)
|
||||
|
||||
# Raw data
|
||||
lighthouse_json = Column(Text, nullable=True) # JSON string
|
||||
scan_strategy = Column(String(20), nullable=True) # mobile or desktop
|
||||
scan_error = Column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
prospect = relationship("Prospect", back_populates="performance_profile")
|
||||
82
app/modules/prospecting/models/prospect.py
Normal file
82
app/modules/prospecting/models/prospect.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# app/modules/prospecting/models/prospect.py
|
||||
"""
|
||||
Prospect model - core entity for lead discovery.
|
||||
|
||||
Supports two channels:
|
||||
- digital: discovered via domain scanning (.lu domains)
|
||||
- offline: manually captured (street encounters, networking)
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Enum, Float, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class ProspectChannel(str, enum.Enum):
|
||||
DIGITAL = "digital"
|
||||
OFFLINE = "offline"
|
||||
|
||||
|
||||
class ProspectStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
PARKED = "parked"
|
||||
ERROR = "error"
|
||||
CONTACTED = "contacted"
|
||||
CONVERTED = "converted"
|
||||
|
||||
|
||||
class Prospect(Base, TimestampMixin):
|
||||
"""Represents a business prospect (potential client)."""
|
||||
|
||||
__tablename__ = "prospects"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
channel = Column(Enum(ProspectChannel), nullable=False, default=ProspectChannel.DIGITAL)
|
||||
business_name = Column(String(255), nullable=True)
|
||||
domain_name = Column(String(255), nullable=True, unique=True, index=True)
|
||||
status = Column(Enum(ProspectStatus), nullable=False, default=ProspectStatus.PENDING)
|
||||
source = Column(String(100), nullable=True)
|
||||
|
||||
# Website status (digital channel)
|
||||
has_website = Column(Boolean, nullable=True)
|
||||
uses_https = Column(Boolean, nullable=True)
|
||||
http_status_code = Column(Integer, nullable=True)
|
||||
redirect_url = Column(Text, nullable=True)
|
||||
|
||||
# Location (offline channel)
|
||||
address = Column(String(500), nullable=True)
|
||||
city = Column(String(100), nullable=True)
|
||||
postal_code = Column(String(10), nullable=True)
|
||||
country = Column(String(2), nullable=False, default="LU")
|
||||
|
||||
# Notes and metadata
|
||||
notes = Column(Text, nullable=True)
|
||||
tags = Column(Text, nullable=True) # JSON string of tags
|
||||
|
||||
# Capture info
|
||||
captured_by_user_id = Column(Integer, nullable=True)
|
||||
location_lat = Column(Float, nullable=True)
|
||||
location_lng = Column(Float, nullable=True)
|
||||
|
||||
# Scan timestamps
|
||||
last_http_check_at = Column(DateTime, nullable=True)
|
||||
last_tech_scan_at = Column(DateTime, nullable=True)
|
||||
last_perf_scan_at = Column(DateTime, nullable=True)
|
||||
last_contact_scrape_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
tech_profile = relationship("ProspectTechProfile", back_populates="prospect", uselist=False, cascade="all, delete-orphan")
|
||||
performance_profile = relationship("ProspectPerformanceProfile", back_populates="prospect", uselist=False, cascade="all, delete-orphan")
|
||||
score = relationship("ProspectScore", back_populates="prospect", uselist=False, cascade="all, delete-orphan")
|
||||
contacts = relationship("ProspectContact", back_populates="prospect", cascade="all, delete-orphan")
|
||||
interactions = relationship("ProspectInteraction", back_populates="prospect", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self.business_name or self.domain_name or f"Prospect #{self.id}"
|
||||
44
app/modules/prospecting/models/prospect_contact.py
Normal file
44
app/modules/prospecting/models/prospect_contact.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# app/modules/prospecting/models/prospect_contact.py
|
||||
"""
|
||||
Contact information for a prospect.
|
||||
|
||||
Supports both auto-scraped (digital) and manually entered (offline) contacts.
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Boolean, Column, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class ContactType(str, enum.Enum):
|
||||
EMAIL = "email"
|
||||
PHONE = "phone"
|
||||
ADDRESS = "address"
|
||||
SOCIAL = "social"
|
||||
FORM = "form"
|
||||
|
||||
|
||||
class ProspectContact(Base, TimestampMixin):
|
||||
"""Contact information associated with a prospect."""
|
||||
|
||||
__tablename__ = "prospect_contacts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
contact_type = Column(Enum(ContactType), nullable=False)
|
||||
value = Column(String(500), nullable=False)
|
||||
label = Column(String(100), nullable=True) # e.g., "info", "sales", "main"
|
||||
source_url = Column(Text, nullable=True) # Page where contact was found
|
||||
source_element = Column(String(100), nullable=True) # e.g., "mailto", "tel", "contact-form"
|
||||
|
||||
is_validated = Column(Boolean, nullable=False, default=False)
|
||||
validation_error = Column(Text, nullable=True)
|
||||
is_primary = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Relationships
|
||||
prospect = relationship("Prospect", back_populates="contacts")
|
||||
46
app/modules/prospecting/models/prospect_score.py
Normal file
46
app/modules/prospecting/models/prospect_score.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# app/modules/prospecting/models/prospect_score.py
|
||||
"""
|
||||
Opportunity score for a prospect.
|
||||
|
||||
Scoring algorithm: 0-100 total
|
||||
- Technical Health: max 40pts
|
||||
- Modernity: max 25pts
|
||||
- Business Value: max 25pts
|
||||
- Engagement: max 10pts
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class ProspectScore(Base, TimestampMixin):
|
||||
"""Opportunity score computed from prospect analysis."""
|
||||
|
||||
__tablename__ = "prospect_scores"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, unique=True)
|
||||
|
||||
# Overall score
|
||||
score = Column(Integer, nullable=False, default=0, index=True)
|
||||
|
||||
# Component scores
|
||||
technical_health_score = Column(Integer, nullable=False, default=0) # max 40
|
||||
modernity_score = Column(Integer, nullable=False, default=0) # max 25
|
||||
business_value_score = Column(Integer, nullable=False, default=0) # max 25
|
||||
engagement_score = Column(Integer, nullable=False, default=0) # max 10
|
||||
|
||||
# Detailed breakdown
|
||||
reason_flags = Column(Text, nullable=True) # JSON array of flag strings
|
||||
score_breakdown = Column(Text, nullable=True) # JSON dict of flag -> points
|
||||
|
||||
# Lead tier classification
|
||||
lead_tier = Column(String(20), nullable=True, index=True) # top_priority, quick_win, strategic, low_priority
|
||||
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
prospect = relationship("Prospect", back_populates="score")
|
||||
61
app/modules/prospecting/models/scan_job.py
Normal file
61
app/modules/prospecting/models/scan_job.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# app/modules/prospecting/models/scan_job.py
|
||||
"""
|
||||
Scan job tracking for batch enrichment operations.
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, Integer, String, Text
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class JobType(str, enum.Enum):
|
||||
IMPORT = "import"
|
||||
HTTP_CHECK = "http_check"
|
||||
TECH_SCAN = "tech_scan"
|
||||
PERFORMANCE_SCAN = "performance_scan"
|
||||
CONTACT_SCRAPE = "contact_scrape"
|
||||
SCORE_COMPUTE = "score_compute"
|
||||
FULL_ENRICHMENT = "full_enrichment"
|
||||
SECURITY_AUDIT = "security_audit"
|
||||
|
||||
|
||||
class JobStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ProspectScanJob(Base, TimestampMixin):
|
||||
"""Tracks batch scanning operations."""
|
||||
|
||||
__tablename__ = "prospect_scan_jobs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
job_type = Column(Enum(JobType), nullable=False)
|
||||
status = Column(Enum(JobStatus), nullable=False, default=JobStatus.PENDING)
|
||||
|
||||
total_items = Column(Integer, nullable=False, default=0)
|
||||
processed_items = Column(Integer, nullable=False, default=0)
|
||||
failed_items = Column(Integer, nullable=False, default=0)
|
||||
skipped_items = Column(Integer, nullable=False, default=0)
|
||||
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
config = Column(Text, nullable=True) # JSON string
|
||||
result_summary = Column(Text, nullable=True) # JSON string
|
||||
error_log = Column(Text, nullable=True)
|
||||
source_file = Column(String(500), nullable=True)
|
||||
|
||||
celery_task_id = Column(String(255), nullable=True)
|
||||
|
||||
@property
|
||||
def progress_percent(self) -> float:
|
||||
if self.total_items == 0:
|
||||
return 0.0
|
||||
return round(self.processed_items / self.total_items * 100, 1)
|
||||
51
app/modules/prospecting/models/tech_profile.py
Normal file
51
app/modules/prospecting/models/tech_profile.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# app/modules/prospecting/models/tech_profile.py
|
||||
"""
|
||||
Technology profile for a prospect's website.
|
||||
|
||||
Stores CMS, server, framework, analytics, and other
|
||||
technology detection results from website scanning.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class ProspectTechProfile(Base, TimestampMixin):
|
||||
"""Technology profile detected from a prospect's website."""
|
||||
|
||||
__tablename__ = "prospect_tech_profiles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, unique=True)
|
||||
|
||||
# CMS Detection
|
||||
cms = Column(String(100), nullable=True)
|
||||
cms_version = Column(String(50), nullable=True)
|
||||
|
||||
# Server
|
||||
server = Column(String(100), nullable=True)
|
||||
server_version = Column(String(50), nullable=True)
|
||||
hosting_provider = Column(String(100), nullable=True)
|
||||
cdn = Column(String(100), nullable=True)
|
||||
|
||||
# SSL
|
||||
has_valid_cert = Column(Boolean, nullable=True)
|
||||
cert_issuer = Column(String(200), nullable=True)
|
||||
cert_expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Frontend
|
||||
js_framework = Column(String(100), nullable=True)
|
||||
analytics = Column(String(200), nullable=True)
|
||||
tag_manager = Column(String(100), nullable=True)
|
||||
ecommerce_platform = Column(String(100), nullable=True)
|
||||
|
||||
# Raw data
|
||||
tech_stack_json = Column(Text, nullable=True) # JSON string
|
||||
scan_source = Column(String(50), nullable=True)
|
||||
scan_error = Column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
prospect = relationship("Prospect", back_populates="tech_profile")
|
||||
1
app/modules/prospecting/routes/__init__.py
Normal file
1
app/modules/prospecting/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/prospecting/routes/__init__.py
|
||||
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,
|
||||
)
|
||||
1
app/modules/prospecting/routes/pages/__init__.py
Normal file
1
app/modules/prospecting/routes/pages/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/prospecting/routes/pages/__init__.py
|
||||
123
app/modules/prospecting/routes/pages/admin.py
Normal file
123
app/modules/prospecting/routes/pages/admin.py
Normal file
@@ -0,0 +1,123 @@
|
||||
# app/modules/prospecting/routes/pages/admin.py
|
||||
"""
|
||||
Prospecting Admin Page Routes (HTML rendering).
|
||||
|
||||
Admin pages for lead discovery and campaign management:
|
||||
- Dashboard - Overview with stats and charts
|
||||
- Prospects - Prospect list with filters
|
||||
- Prospect Detail - Single prospect view with tabs
|
||||
- Leads - Scored lead list
|
||||
- Quick Capture - Mobile-friendly lead capture form
|
||||
- Scan Jobs - Scan job monitoring
|
||||
- Campaigns - Campaign template management
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_menu_access
|
||||
from app.modules.core.utils.page_context import get_admin_context
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.models import User
|
||||
from app.templates_config import templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/prospecting", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_prospecting_dashboard(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("prospecting-dashboard", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Render prospecting dashboard page."""
|
||||
return templates.TemplateResponse(
|
||||
"prospecting/admin/dashboard.html",
|
||||
get_admin_context(request, db, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/prospecting/prospects", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_prospects_list(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("prospects", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Render prospects list page."""
|
||||
return templates.TemplateResponse(
|
||||
"prospecting/admin/prospects.html",
|
||||
get_admin_context(request, db, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/prospecting/prospects/{prospect_id}",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def admin_prospect_detail(
|
||||
request: Request,
|
||||
prospect_id: int = Path(..., description="Prospect ID"),
|
||||
current_user: User = Depends(require_menu_access("prospects", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Render single prospect detail page."""
|
||||
context = get_admin_context(request, db, current_user)
|
||||
context["prospect_id"] = prospect_id
|
||||
return templates.TemplateResponse(
|
||||
"prospecting/admin/prospect-detail.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/prospecting/leads", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_leads_list(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("leads", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Render leads list page."""
|
||||
return templates.TemplateResponse(
|
||||
"prospecting/admin/leads.html",
|
||||
get_admin_context(request, db, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/prospecting/capture", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_quick_capture(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("capture", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Render mobile-friendly quick capture form."""
|
||||
return templates.TemplateResponse(
|
||||
"prospecting/admin/capture.html",
|
||||
get_admin_context(request, db, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/prospecting/scan-jobs", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_scan_jobs(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("scan-jobs", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Render scan jobs monitoring page."""
|
||||
return templates.TemplateResponse(
|
||||
"prospecting/admin/scan-jobs.html",
|
||||
get_admin_context(request, db, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/prospecting/campaigns", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_campaigns(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("campaigns", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Render campaign management page."""
|
||||
return templates.TemplateResponse(
|
||||
"prospecting/admin/campaigns.html",
|
||||
get_admin_context(request, db, current_user),
|
||||
)
|
||||
66
app/modules/prospecting/schemas/__init__.py
Normal file
66
app/modules/prospecting/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
103
app/modules/prospecting/schemas/campaign.py
Normal file
103
app/modules/prospecting/schemas/campaign.py
Normal 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
|
||||
34
app/modules/prospecting/schemas/contact.py
Normal file
34
app/modules/prospecting/schemas/contact.py
Normal 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
|
||||
69
app/modules/prospecting/schemas/enrichment.py
Normal file
69
app/modules/prospecting/schemas/enrichment.py
Normal 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
|
||||
44
app/modules/prospecting/schemas/interaction.py
Normal file
44
app/modules/prospecting/schemas/interaction.py
Normal 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
|
||||
33
app/modules/prospecting/schemas/performance_profile.py
Normal file
33
app/modules/prospecting/schemas/performance_profile.py
Normal 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
|
||||
122
app/modules/prospecting/schemas/prospect.py
Normal file
122
app/modules/prospecting/schemas/prospect.py
Normal 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()
|
||||
38
app/modules/prospecting/schemas/scan_job.py
Normal file
38
app/modules/prospecting/schemas/scan_job.py
Normal 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
|
||||
27
app/modules/prospecting/schemas/score.py
Normal file
27
app/modules/prospecting/schemas/score.py
Normal 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
|
||||
33
app/modules/prospecting/schemas/tech_profile.py
Normal file
33
app/modules/prospecting/schemas/tech_profile.py
Normal 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
|
||||
1
app/modules/prospecting/services/__init__.py
Normal file
1
app/modules/prospecting/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/prospecting/services/__init__.py
|
||||
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()
|
||||
369
app/modules/prospecting/services/enrichment_service.py
Normal file
369
app/modules/prospecting/services/enrichment_service.py
Normal file
@@ -0,0 +1,369 @@
|
||||
# app/modules/prospecting/services/enrichment_service.py
|
||||
"""
|
||||
Enrichment service for prospect scanning pipeline.
|
||||
|
||||
Migrated from marketing-.lu-domains/app/services/enrichment_service.py.
|
||||
Performs passive HTTP checks, technology detection, performance audits,
|
||||
and contact scraping for digital prospects.
|
||||
|
||||
Uses `requests` (sync) to match Orion's tech stack.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import requests
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.prospecting.config import config
|
||||
from app.modules.prospecting.models import (
|
||||
Prospect,
|
||||
ProspectContact,
|
||||
ProspectPerformanceProfile,
|
||||
ProspectTechProfile,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# CMS detection patterns
|
||||
CMS_PATTERNS = {
|
||||
"wordpress": [r"wp-content", r"wp-includes", r"wordpress"],
|
||||
"drupal": [r"drupal", r"sites/default", r"sites/all"],
|
||||
"joomla": [r"/media/jui/", r"joomla", r"/components/com_"],
|
||||
"shopify": [r"cdn\.shopify\.com", r"shopify"],
|
||||
"wix": [r"wix\.com", r"wixstatic\.com", r"parastorage\.com"],
|
||||
"squarespace": [r"squarespace\.com", r"sqsp\.com"],
|
||||
"webflow": [r"webflow\.com", r"webflow\.io"],
|
||||
"typo3": [r"typo3", r"/typo3conf/"],
|
||||
"prestashop": [r"prestashop", r"/modules/ps_"],
|
||||
"magento": [r"magento", r"mage/", r"/static/version"],
|
||||
}
|
||||
|
||||
JS_FRAMEWORK_PATTERNS = {
|
||||
"react": [r"react", r"__NEXT_DATA__", r"_next/"],
|
||||
"vue": [r"vue\.js", r"vue\.min\.js", r"__vue__"],
|
||||
"angular": [r"angular", r"ng-version"],
|
||||
"jquery": [r"jquery"],
|
||||
"alpine": [r"alpine\.js", r"alpinejs"],
|
||||
}
|
||||
|
||||
ANALYTICS_PATTERNS = {
|
||||
"google_analytics": [r"google-analytics\.com", r"gtag/js", r"ga\.js"],
|
||||
"google_tag_manager": [r"googletagmanager\.com", r"gtm\.js"],
|
||||
"matomo": [r"matomo", r"piwik"],
|
||||
"facebook_pixel": [r"facebook\.net/en_US/fbevents"],
|
||||
}
|
||||
|
||||
|
||||
class EnrichmentService:
|
||||
"""Service for prospect enrichment via passive scanning."""
|
||||
|
||||
def check_http(self, db: Session, prospect: Prospect) -> dict:
|
||||
"""Check HTTP connectivity for a prospect's domain."""
|
||||
result = {
|
||||
"has_website": False,
|
||||
"uses_https": False,
|
||||
"http_status_code": None,
|
||||
"redirect_url": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
domain = prospect.domain_name
|
||||
if not domain:
|
||||
result["error"] = "No domain name"
|
||||
return result
|
||||
|
||||
# Try HTTPS first, then HTTP
|
||||
for scheme in ["https", "http"]:
|
||||
try:
|
||||
url = f"{scheme}://{domain}"
|
||||
response = requests.get(
|
||||
url,
|
||||
timeout=config.http_timeout,
|
||||
allow_redirects=True,
|
||||
verify=False, # noqa: SEC047 passive scan, not sending sensitive data
|
||||
)
|
||||
result["has_website"] = True
|
||||
result["uses_https"] = scheme == "https"
|
||||
result["http_status_code"] = response.status_code
|
||||
if response.url != url:
|
||||
result["redirect_url"] = str(response.url)
|
||||
break
|
||||
except requests.exceptions.Timeout:
|
||||
result["error"] = f"Timeout on {scheme}"
|
||||
except requests.exceptions.RequestException as e:
|
||||
result["error"] = str(e)
|
||||
if scheme == "https":
|
||||
continue
|
||||
break
|
||||
|
||||
# Update prospect
|
||||
prospect.has_website = result["has_website"]
|
||||
prospect.uses_https = result["uses_https"]
|
||||
prospect.http_status_code = result["http_status_code"]
|
||||
prospect.redirect_url = result["redirect_url"]
|
||||
prospect.last_http_check_at = datetime.now(UTC)
|
||||
|
||||
if result["has_website"]:
|
||||
prospect.status = "active"
|
||||
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
def scan_tech_stack(self, db: Session, prospect: Prospect) -> ProspectTechProfile | None:
|
||||
"""Scan technology stack from prospect's website HTML."""
|
||||
domain = prospect.domain_name
|
||||
if not domain or not prospect.has_website:
|
||||
return None
|
||||
|
||||
scheme = "https" if prospect.uses_https else "http"
|
||||
url = f"{scheme}://{domain}"
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
timeout=config.http_timeout,
|
||||
allow_redirects=True,
|
||||
verify=False, # noqa: SEC047 passive scan, not sending sensitive data
|
||||
)
|
||||
html = response.text.lower()
|
||||
headers = dict(response.headers)
|
||||
|
||||
cms = self._detect_cms(html)
|
||||
js_framework = self._detect_js_framework(html)
|
||||
analytics = self._detect_analytics(html)
|
||||
server = headers.get("Server", "").split("/")[0] if "Server" in headers else None
|
||||
server_version = None
|
||||
if server and "/" in headers.get("Server", ""):
|
||||
server_version = headers["Server"].split("/", 1)[1].strip()
|
||||
|
||||
# SSL certificate check
|
||||
has_valid_cert = None
|
||||
cert_issuer = None
|
||||
cert_expires_at = None
|
||||
if prospect.uses_https:
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
with ctx.wrap_socket(
|
||||
socket.create_connection((domain, 443), timeout=5),
|
||||
server_hostname=domain,
|
||||
) as sock:
|
||||
cert = sock.getpeercert()
|
||||
has_valid_cert = True
|
||||
cert_issuer = dict(x[0] for x in cert.get("issuer", [()])).get("organizationName")
|
||||
not_after = cert.get("notAfter")
|
||||
if not_after:
|
||||
cert_expires_at = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z")
|
||||
except Exception:
|
||||
has_valid_cert = False
|
||||
|
||||
# Upsert tech profile
|
||||
profile = prospect.tech_profile
|
||||
if not profile:
|
||||
profile = ProspectTechProfile(prospect_id=prospect.id)
|
||||
db.add(profile)
|
||||
|
||||
profile.cms = cms
|
||||
profile.server = server
|
||||
profile.server_version = server_version
|
||||
profile.js_framework = js_framework
|
||||
profile.analytics = analytics
|
||||
profile.has_valid_cert = has_valid_cert
|
||||
profile.cert_issuer = cert_issuer
|
||||
profile.cert_expires_at = cert_expires_at
|
||||
profile.scan_source = "basic_http"
|
||||
|
||||
prospect.last_tech_scan_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return profile
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Tech scan failed for %s: %s", domain, e)
|
||||
if prospect.tech_profile:
|
||||
prospect.tech_profile.scan_error = str(e)
|
||||
prospect.last_tech_scan_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
def scan_performance(self, db: Session, prospect: Prospect) -> ProspectPerformanceProfile | None:
|
||||
"""Run PageSpeed Insights audit for a prospect's website."""
|
||||
domain = prospect.domain_name
|
||||
if not domain or not prospect.has_website:
|
||||
return None
|
||||
|
||||
scheme = "https" if prospect.uses_https else "http"
|
||||
url = f"{scheme}://{domain}"
|
||||
|
||||
api_url = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed"
|
||||
params = {
|
||||
"url": url,
|
||||
"strategy": "mobile",
|
||||
"category": ["performance", "accessibility", "best-practices", "seo"],
|
||||
}
|
||||
if config.pagespeed_api_key:
|
||||
params["key"] = config.pagespeed_api_key
|
||||
|
||||
try:
|
||||
response = requests.get(api_url, params=params, timeout=60)
|
||||
data = response.json()
|
||||
|
||||
lighthouse = data.get("lighthouseResult", {})
|
||||
categories = lighthouse.get("categories", {})
|
||||
audits = lighthouse.get("audits", {})
|
||||
|
||||
perf_score = int((categories.get("performance", {}).get("score") or 0) * 100)
|
||||
accessibility = int((categories.get("accessibility", {}).get("score") or 0) * 100)
|
||||
best_practices = int((categories.get("best-practices", {}).get("score") or 0) * 100)
|
||||
seo = int((categories.get("seo", {}).get("score") or 0) * 100)
|
||||
|
||||
# Upsert performance profile
|
||||
profile = prospect.performance_profile
|
||||
if not profile:
|
||||
profile = ProspectPerformanceProfile(prospect_id=prospect.id)
|
||||
db.add(profile)
|
||||
|
||||
profile.performance_score = perf_score
|
||||
profile.accessibility_score = accessibility
|
||||
profile.best_practices_score = best_practices
|
||||
profile.seo_score = seo
|
||||
|
||||
# Core Web Vitals
|
||||
fcp = audits.get("first-contentful-paint", {}).get("numericValue")
|
||||
profile.first_contentful_paint_ms = int(fcp) if fcp else None
|
||||
lcp = audits.get("largest-contentful-paint", {}).get("numericValue")
|
||||
profile.largest_contentful_paint_ms = int(lcp) if lcp else None
|
||||
tbt = audits.get("total-blocking-time", {}).get("numericValue")
|
||||
profile.total_blocking_time_ms = int(tbt) if tbt else None
|
||||
cls_val = audits.get("cumulative-layout-shift", {}).get("numericValue")
|
||||
profile.cumulative_layout_shift = cls_val
|
||||
si = audits.get("speed-index", {}).get("numericValue")
|
||||
profile.speed_index = int(si) if si else None
|
||||
tti = audits.get("interactive", {}).get("numericValue")
|
||||
profile.time_to_interactive_ms = int(tti) if tti else None
|
||||
|
||||
# Mobile-friendly check
|
||||
viewport = audits.get("viewport", {}).get("score")
|
||||
profile.viewport_configured = viewport == 1 if viewport is not None else None
|
||||
profile.is_mobile_friendly = profile.viewport_configured
|
||||
profile.scan_strategy = "mobile"
|
||||
|
||||
prospect.last_perf_scan_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return profile
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Performance scan failed for %s: %s", domain, e)
|
||||
prospect.last_perf_scan_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
def scrape_contacts(self, db: Session, prospect: Prospect) -> list[ProspectContact]:
|
||||
"""Scrape email and phone contacts from prospect's website."""
|
||||
domain = prospect.domain_name
|
||||
if not domain or not prospect.has_website:
|
||||
return []
|
||||
|
||||
scheme = "https" if prospect.uses_https else "http"
|
||||
base_url = f"{scheme}://{domain}"
|
||||
paths = ["", "/contact", "/kontakt", "/impressum", "/about"]
|
||||
|
||||
email_pattern = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
|
||||
phone_pattern = re.compile(r"(?:\+352|00352)?[\s.-]?\d{2,3}[\s.-]?\d{2,3}[\s.-]?\d{2,3}")
|
||||
|
||||
false_positive_domains = {"example.com", "email.com", "domain.com", "wordpress.org", "w3.org", "schema.org"}
|
||||
found_emails = set()
|
||||
found_phones = set()
|
||||
contacts = []
|
||||
|
||||
session = requests.Session()
|
||||
session.verify = False # noqa: SEC047 passive scan, not sending sensitive data
|
||||
session.headers.update({"User-Agent": "Mozilla/5.0 (compatible; OrionBot/1.0)"})
|
||||
|
||||
for path in paths:
|
||||
try:
|
||||
url = base_url + path
|
||||
response = session.get(url, timeout=config.http_timeout, allow_redirects=True)
|
||||
if response.status_code != 200:
|
||||
continue
|
||||
html = response.text
|
||||
|
||||
for email in email_pattern.findall(html):
|
||||
email_domain = email.split("@")[1].lower()
|
||||
if email_domain not in false_positive_domains and email not in found_emails:
|
||||
found_emails.add(email)
|
||||
contacts.append(ProspectContact(
|
||||
prospect_id=prospect.id,
|
||||
contact_type="email",
|
||||
value=email.lower(),
|
||||
source_url=url,
|
||||
source_element="regex",
|
||||
))
|
||||
|
||||
for phone in phone_pattern.findall(html):
|
||||
phone_clean = re.sub(r"[\s.-]", "", phone)
|
||||
if len(phone_clean) >= 8 and phone_clean not in found_phones:
|
||||
found_phones.add(phone_clean)
|
||||
contacts.append(ProspectContact(
|
||||
prospect_id=prospect.id,
|
||||
contact_type="phone",
|
||||
value=phone_clean,
|
||||
source_url=url,
|
||||
source_element="regex",
|
||||
))
|
||||
except Exception as e:
|
||||
logger.debug("Contact scrape failed for %s%s: %s", domain, path, e)
|
||||
|
||||
session.close()
|
||||
|
||||
# Save contacts (replace existing auto-scraped ones)
|
||||
db.query(ProspectContact).filter(
|
||||
ProspectContact.prospect_id == prospect.id,
|
||||
ProspectContact.source_element == "regex",
|
||||
).delete()
|
||||
|
||||
for contact in contacts:
|
||||
db.add(contact)
|
||||
|
||||
# Mark first email and phone as primary
|
||||
if contacts:
|
||||
for c in contacts:
|
||||
if c.contact_type == "email":
|
||||
c.is_primary = True
|
||||
break
|
||||
for c in contacts:
|
||||
if c.contact_type == "phone":
|
||||
c.is_primary = True
|
||||
break
|
||||
|
||||
prospect.last_contact_scrape_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return contacts
|
||||
|
||||
def _detect_cms(self, html: str) -> str | None:
|
||||
for cms, patterns in CMS_PATTERNS.items():
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, html):
|
||||
return cms
|
||||
return None
|
||||
|
||||
def _detect_js_framework(self, html: str) -> str | None:
|
||||
for framework, patterns in JS_FRAMEWORK_PATTERNS.items():
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, html):
|
||||
return framework
|
||||
return None
|
||||
|
||||
def _detect_analytics(self, html: str) -> str | None:
|
||||
found = []
|
||||
for tool, patterns in ANALYTICS_PATTERNS.items():
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, html):
|
||||
found.append(tool)
|
||||
break
|
||||
return ",".join(found) if found else None
|
||||
|
||||
|
||||
enrichment_service = EnrichmentService()
|
||||
77
app/modules/prospecting/services/interaction_service.py
Normal file
77
app/modules/prospecting/services/interaction_service.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# app/modules/prospecting/services/interaction_service.py
|
||||
"""
|
||||
Interaction tracking service.
|
||||
|
||||
Manages logging of all touchpoints with prospects:
|
||||
calls, emails, meetings, visits, notes, etc.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.prospecting.models import ProspectInteraction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InteractionService:
|
||||
"""Service for prospect interaction management."""
|
||||
|
||||
def create(
|
||||
self,
|
||||
db: Session,
|
||||
prospect_id: int,
|
||||
user_id: int,
|
||||
data: dict,
|
||||
) -> ProspectInteraction:
|
||||
"""Log a new interaction."""
|
||||
interaction = ProspectInteraction(
|
||||
prospect_id=prospect_id,
|
||||
interaction_type=data["interaction_type"],
|
||||
subject=data.get("subject"),
|
||||
notes=data.get("notes"),
|
||||
outcome=data.get("outcome"),
|
||||
next_action=data.get("next_action"),
|
||||
next_action_date=data.get("next_action_date"),
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
db.add(interaction)
|
||||
db.commit()
|
||||
db.refresh(interaction)
|
||||
logger.info("Interaction logged for prospect %d: %s", prospect_id, data["interaction_type"])
|
||||
return interaction
|
||||
|
||||
def get_for_prospect(
|
||||
self,
|
||||
db: Session,
|
||||
prospect_id: int,
|
||||
) -> list[ProspectInteraction]:
|
||||
"""Get all interactions for a prospect, newest first."""
|
||||
return (
|
||||
db.query(ProspectInteraction)
|
||||
.filter(ProspectInteraction.prospect_id == prospect_id)
|
||||
.order_by(ProspectInteraction.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_upcoming_actions(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
before_date: date | None = None,
|
||||
) -> list[ProspectInteraction]:
|
||||
"""Get interactions with upcoming follow-up actions."""
|
||||
query = db.query(ProspectInteraction).filter(
|
||||
ProspectInteraction.next_action.isnot(None),
|
||||
ProspectInteraction.next_action_date.isnot(None),
|
||||
)
|
||||
|
||||
if before_date:
|
||||
query = query.filter(ProspectInteraction.next_action_date <= before_date)
|
||||
|
||||
return query.order_by(ProspectInteraction.next_action_date.asc()).all()
|
||||
|
||||
|
||||
interaction_service = InteractionService()
|
||||
153
app/modules/prospecting/services/lead_service.py
Normal file
153
app/modules/prospecting/services/lead_service.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# app/modules/prospecting/services/lead_service.py
|
||||
"""
|
||||
Lead filtering and export service.
|
||||
|
||||
Provides filtered views of scored prospects and CSV export capabilities.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.modules.prospecting.models import (
|
||||
Prospect,
|
||||
ProspectScore,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LeadService:
|
||||
"""Service for lead retrieval and export."""
|
||||
|
||||
def get_leads(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
min_score: int = 0,
|
||||
max_score: int = 100,
|
||||
lead_tier: str | None = None,
|
||||
channel: str | None = None,
|
||||
has_email: bool | None = None,
|
||||
has_phone: bool | None = None,
|
||||
reason_flag: str | None = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get filtered leads with scores."""
|
||||
query = (
|
||||
db.query(Prospect)
|
||||
.join(ProspectScore)
|
||||
.options(
|
||||
joinedload(Prospect.score),
|
||||
joinedload(Prospect.contacts),
|
||||
)
|
||||
.filter(
|
||||
ProspectScore.score >= min_score,
|
||||
ProspectScore.score <= max_score,
|
||||
)
|
||||
)
|
||||
|
||||
if lead_tier:
|
||||
query = query.filter(ProspectScore.lead_tier == lead_tier)
|
||||
if channel:
|
||||
query = query.filter(Prospect.channel == channel)
|
||||
if reason_flag:
|
||||
query = query.filter(ProspectScore.reason_flags.contains(reason_flag))
|
||||
|
||||
total = query.count()
|
||||
prospects = (
|
||||
query.order_by(ProspectScore.score.desc())
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
.all()
|
||||
)
|
||||
|
||||
leads = []
|
||||
for p in prospects:
|
||||
contacts = p.contacts or []
|
||||
primary_email = next((c.value for c in contacts if c.contact_type == "email" and c.is_primary), None)
|
||||
if not primary_email:
|
||||
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" and c.is_primary), None)
|
||||
if not primary_phone:
|
||||
primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), None)
|
||||
|
||||
# Filter by contact availability if requested
|
||||
if has_email is True and not primary_email:
|
||||
continue
|
||||
if has_email is False and primary_email:
|
||||
continue
|
||||
if has_phone is True and not primary_phone:
|
||||
continue
|
||||
if has_phone is False and primary_phone:
|
||||
continue
|
||||
|
||||
reason_flags = json.loads(p.score.reason_flags) if p.score and p.score.reason_flags else []
|
||||
|
||||
leads.append({
|
||||
"id": p.id,
|
||||
"business_name": p.business_name,
|
||||
"domain_name": p.domain_name,
|
||||
"channel": str(p.channel.value) if p.channel else None,
|
||||
"score": p.score.score if p.score else 0,
|
||||
"lead_tier": p.score.lead_tier if p.score else None,
|
||||
"reason_flags": reason_flags,
|
||||
"primary_email": primary_email,
|
||||
"primary_phone": primary_phone,
|
||||
})
|
||||
|
||||
return leads, total
|
||||
|
||||
def get_top_priority(self, db: Session, limit: int = 50) -> list[dict]:
|
||||
leads, _ = self.get_leads(db, min_score=70, per_page=limit)
|
||||
return leads
|
||||
|
||||
def get_quick_wins(self, db: Session, limit: int = 50) -> list[dict]:
|
||||
leads, _ = self.get_leads(db, min_score=50, max_score=69, per_page=limit)
|
||||
return leads
|
||||
|
||||
def export_csv(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
min_score: int = 0,
|
||||
lead_tier: str | None = None,
|
||||
channel: str | None = None,
|
||||
limit: int = 1000,
|
||||
) -> str:
|
||||
"""Export leads to CSV string."""
|
||||
leads, _ = self.get_leads(
|
||||
db,
|
||||
min_score=min_score,
|
||||
lead_tier=lead_tier,
|
||||
channel=channel,
|
||||
per_page=limit,
|
||||
)
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
"Domain", "Business Name", "Channel", "Score", "Tier",
|
||||
"Issues", "Email", "Phone",
|
||||
])
|
||||
|
||||
for lead in leads:
|
||||
writer.writerow([
|
||||
lead["domain_name"] or "",
|
||||
lead["business_name"] or "",
|
||||
lead["channel"] or "",
|
||||
lead["score"],
|
||||
lead["lead_tier"] or "",
|
||||
"; ".join(lead["reason_flags"]),
|
||||
lead["primary_email"] or "",
|
||||
lead["primary_phone"] or "",
|
||||
])
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
lead_service = LeadService()
|
||||
235
app/modules/prospecting/services/prospect_service.py
Normal file
235
app/modules/prospecting/services/prospect_service.py
Normal file
@@ -0,0 +1,235 @@
|
||||
# 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.commit()
|
||||
db.refresh(prospect)
|
||||
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.commit()
|
||||
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.commit()
|
||||
db.refresh(prospect)
|
||||
return prospect
|
||||
|
||||
def delete(self, db: Session, prospect_id: int) -> bool:
|
||||
prospect = self.get_by_id(db, prospect_id)
|
||||
db.delete(prospect)
|
||||
db.commit()
|
||||
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()
|
||||
253
app/modules/prospecting/services/scoring_service.py
Normal file
253
app/modules/prospecting/services/scoring_service.py
Normal file
@@ -0,0 +1,253 @@
|
||||
# app/modules/prospecting/services/scoring_service.py
|
||||
"""
|
||||
Opportunity scoring service.
|
||||
|
||||
Migrated from marketing-.lu-domains/app/services/scoring_service.py.
|
||||
Scores prospects on a 0-100 scale across 4 categories:
|
||||
- Technical Health (max 40pts)
|
||||
- Modernity (max 25pts)
|
||||
- Business Value (max 25pts)
|
||||
- Engagement (max 10pts)
|
||||
|
||||
Extended for offline leads with additional scoring factors.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.prospecting.models import (
|
||||
Prospect,
|
||||
ProspectChannel,
|
||||
ProspectScore,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Outdated CMS list
|
||||
OUTDATED_CMS = {"drupal", "joomla", "typo3"}
|
||||
|
||||
|
||||
class ScoringService:
|
||||
"""Service for computing opportunity scores."""
|
||||
|
||||
def compute_score(self, db: Session, prospect: Prospect) -> ProspectScore:
|
||||
"""Compute or update the opportunity score for a prospect."""
|
||||
tech_health = 0
|
||||
modernity = 0
|
||||
business_value = 0
|
||||
engagement = 0
|
||||
reason_flags = []
|
||||
breakdown = {}
|
||||
|
||||
if prospect.channel == ProspectChannel.OFFLINE:
|
||||
# Offline lead scoring
|
||||
tech_health, modernity, business_value, engagement, reason_flags, breakdown = (
|
||||
self._score_offline(prospect)
|
||||
)
|
||||
else:
|
||||
# Digital lead scoring
|
||||
tech_health, modernity, business_value, engagement, reason_flags, breakdown = (
|
||||
self._score_digital(prospect)
|
||||
)
|
||||
|
||||
total = min(tech_health + modernity + business_value + engagement, 100)
|
||||
|
||||
# Determine lead tier
|
||||
if total >= 70:
|
||||
lead_tier = "top_priority"
|
||||
elif total >= 50:
|
||||
lead_tier = "quick_win"
|
||||
elif total >= 30:
|
||||
lead_tier = "strategic"
|
||||
else:
|
||||
lead_tier = "low_priority"
|
||||
|
||||
# Upsert score
|
||||
score = prospect.score
|
||||
if not score:
|
||||
score = ProspectScore(prospect_id=prospect.id)
|
||||
db.add(score)
|
||||
|
||||
score.score = total
|
||||
score.technical_health_score = tech_health
|
||||
score.modernity_score = modernity
|
||||
score.business_value_score = business_value
|
||||
score.engagement_score = engagement
|
||||
score.reason_flags = json.dumps(reason_flags)
|
||||
score.score_breakdown = json.dumps(breakdown)
|
||||
score.lead_tier = lead_tier
|
||||
|
||||
db.commit()
|
||||
logger.info("Scored prospect %d: %d (%s)", prospect.id, total, lead_tier)
|
||||
return score
|
||||
|
||||
def compute_all(self, db: Session, limit: int | None = None) -> int:
|
||||
"""Compute scores for all prospects. Returns count of scored prospects."""
|
||||
query = db.query(Prospect)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
count = 0
|
||||
for prospect in query.all():
|
||||
self.compute_score(db, prospect)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def _score_digital(self, prospect: Prospect) -> tuple:
|
||||
"""Score a digital (domain-scanned) prospect."""
|
||||
tech_health = 0
|
||||
modernity = 0
|
||||
business_value = 0
|
||||
engagement = 0
|
||||
flags = []
|
||||
breakdown = {}
|
||||
|
||||
# === TECHNICAL HEALTH (max 40) ===
|
||||
if not prospect.uses_https:
|
||||
tech_health += 15
|
||||
flags.append("no_ssl")
|
||||
breakdown["no_ssl"] = 15
|
||||
|
||||
perf = prospect.performance_profile
|
||||
if perf and perf.performance_score is not None:
|
||||
if perf.performance_score < 30:
|
||||
tech_health += 15
|
||||
flags.append("very_slow")
|
||||
breakdown["very_slow"] = 15
|
||||
elif perf.performance_score < 50:
|
||||
tech_health += 10
|
||||
flags.append("slow")
|
||||
breakdown["slow"] = 10
|
||||
elif perf.performance_score < 70:
|
||||
tech_health += 5
|
||||
flags.append("moderate_speed")
|
||||
breakdown["moderate_speed"] = 5
|
||||
|
||||
if perf.is_mobile_friendly is False:
|
||||
tech_health += 10
|
||||
flags.append("not_mobile_friendly")
|
||||
breakdown["not_mobile_friendly"] = 10
|
||||
|
||||
tech_health = min(tech_health, 40)
|
||||
|
||||
# === MODERNITY (max 25) ===
|
||||
tp = prospect.tech_profile
|
||||
if tp:
|
||||
if tp.cms and tp.cms.lower() in OUTDATED_CMS:
|
||||
modernity += 15
|
||||
flags.append("outdated_cms")
|
||||
breakdown["outdated_cms"] = 15
|
||||
elif tp.cms is None and prospect.has_website:
|
||||
modernity += 5
|
||||
flags.append("unknown_cms")
|
||||
breakdown["unknown_cms"] = 5
|
||||
|
||||
if tp.js_framework and tp.js_framework.lower() == "jquery":
|
||||
modernity += 5
|
||||
flags.append("legacy_js")
|
||||
breakdown["legacy_js"] = 5
|
||||
|
||||
if not tp.analytics:
|
||||
modernity += 5
|
||||
flags.append("no_analytics")
|
||||
breakdown["no_analytics"] = 5
|
||||
|
||||
modernity = min(modernity, 25)
|
||||
|
||||
# === BUSINESS VALUE (max 25) ===
|
||||
if prospect.has_website:
|
||||
business_value += 10
|
||||
breakdown["has_website"] = 10
|
||||
|
||||
if tp and tp.ecommerce_platform:
|
||||
business_value += 10
|
||||
breakdown["has_ecommerce"] = 10
|
||||
|
||||
if prospect.domain_name and len(prospect.domain_name) <= 15:
|
||||
business_value += 5
|
||||
breakdown["short_domain"] = 5
|
||||
|
||||
business_value = min(business_value, 25)
|
||||
|
||||
# === ENGAGEMENT (max 10) ===
|
||||
contacts = prospect.contacts or []
|
||||
if contacts:
|
||||
engagement += 5
|
||||
flags.append("has_contacts")
|
||||
breakdown["has_contacts"] = 5
|
||||
|
||||
has_email = any(c.contact_type == "email" for c in contacts)
|
||||
has_phone = any(c.contact_type == "phone" for c in contacts)
|
||||
|
||||
if has_email:
|
||||
engagement += 3
|
||||
flags.append("has_email")
|
||||
breakdown["has_email"] = 3
|
||||
if has_phone:
|
||||
engagement += 2
|
||||
flags.append("has_phone")
|
||||
breakdown["has_phone"] = 2
|
||||
|
||||
engagement = min(engagement, 10)
|
||||
|
||||
return tech_health, modernity, business_value, engagement, flags, breakdown
|
||||
|
||||
def _score_offline(self, prospect: Prospect) -> tuple:
|
||||
"""Score an offline (manually captured) prospect."""
|
||||
tech_health = 0
|
||||
modernity = 0
|
||||
business_value = 0
|
||||
engagement = 0
|
||||
flags = []
|
||||
breakdown = {}
|
||||
|
||||
# Offline prospects without a website are high opportunity
|
||||
if not prospect.has_website:
|
||||
tech_health = 30
|
||||
modernity = 20
|
||||
business_value = 20
|
||||
flags.extend(["no_website"])
|
||||
breakdown["no_website_tech"] = 30
|
||||
breakdown["no_website_mod"] = 20
|
||||
breakdown["no_website_biz"] = 20
|
||||
|
||||
# Check for gmail usage (from contacts)
|
||||
contacts = prospect.contacts or []
|
||||
has_gmail = any(
|
||||
c.contact_type == "email" and "@gmail." in c.value.lower()
|
||||
for c in contacts
|
||||
)
|
||||
if has_gmail:
|
||||
modernity += 10
|
||||
flags.append("uses_gmail")
|
||||
breakdown["uses_gmail"] = 10
|
||||
|
||||
modernity = min(modernity, 25)
|
||||
|
||||
# Engagement - offline leads met in person are warm
|
||||
if prospect.source in ("street", "networking_event", "referral"):
|
||||
engagement += 5
|
||||
flags.append("met_in_person")
|
||||
breakdown["met_in_person"] = 5
|
||||
|
||||
if contacts:
|
||||
has_email = any(c.contact_type == "email" for c in contacts)
|
||||
has_phone = any(c.contact_type == "phone" for c in contacts)
|
||||
if has_email:
|
||||
engagement += 3
|
||||
flags.append("has_email")
|
||||
breakdown["has_email"] = 3
|
||||
if has_phone:
|
||||
engagement += 2
|
||||
flags.append("has_phone")
|
||||
breakdown["has_phone"] = 2
|
||||
|
||||
engagement = min(engagement, 10)
|
||||
|
||||
return tech_health, modernity, business_value, engagement, flags, breakdown
|
||||
|
||||
|
||||
scoring_service = ScoringService()
|
||||
99
app/modules/prospecting/services/stats_service.py
Normal file
99
app/modules/prospecting/services/stats_service.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# app/modules/prospecting/services/stats_service.py
|
||||
"""
|
||||
Statistics service for the prospecting dashboard.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.prospecting.models import (
|
||||
Prospect,
|
||||
ProspectChannel,
|
||||
ProspectScanJob,
|
||||
ProspectScore,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StatsService:
|
||||
"""Service for dashboard statistics and reporting."""
|
||||
|
||||
def get_overview(self, db: Session) -> dict:
|
||||
"""Get overview statistics for the dashboard."""
|
||||
total = db.query(func.count(Prospect.id)).scalar() or 0
|
||||
digital = db.query(func.count(Prospect.id)).filter(Prospect.channel == ProspectChannel.DIGITAL).scalar() or 0
|
||||
offline = db.query(func.count(Prospect.id)).filter(Prospect.channel == ProspectChannel.OFFLINE).scalar() or 0
|
||||
with_website = db.query(func.count(Prospect.id)).filter(Prospect.has_website.is_(True)).scalar() or 0
|
||||
with_https = db.query(func.count(Prospect.id)).filter(Prospect.uses_https.is_(True)).scalar() or 0
|
||||
scored = db.query(func.count(ProspectScore.id)).scalar() or 0
|
||||
avg_score = db.query(func.avg(ProspectScore.score)).scalar()
|
||||
|
||||
# Leads by tier
|
||||
tier_results = (
|
||||
db.query(ProspectScore.lead_tier, func.count(ProspectScore.id))
|
||||
.group_by(ProspectScore.lead_tier)
|
||||
.all()
|
||||
)
|
||||
leads_by_tier = {tier: count for tier, count in tier_results if tier}
|
||||
|
||||
# Common issues (from reason_flags JSON)
|
||||
# Simplified: count scored prospects per tier
|
||||
top_priority = leads_by_tier.get("top_priority", 0)
|
||||
|
||||
return {
|
||||
"total_prospects": total,
|
||||
"digital_count": digital,
|
||||
"offline_count": offline,
|
||||
"with_website": with_website,
|
||||
"with_https": with_https,
|
||||
"scored": scored,
|
||||
"avg_score": round(avg_score, 1) if avg_score else None,
|
||||
"top_priority": top_priority,
|
||||
"leads_by_tier": leads_by_tier,
|
||||
"common_issues": self._get_common_issues(db),
|
||||
}
|
||||
|
||||
def get_scan_jobs(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
status: str | None = None,
|
||||
) -> tuple[list[ProspectScanJob], int]:
|
||||
"""Get paginated scan jobs."""
|
||||
query = db.query(ProspectScanJob)
|
||||
if status:
|
||||
query = query.filter(ProspectScanJob.status == status)
|
||||
|
||||
total = query.count()
|
||||
jobs = (
|
||||
query.order_by(ProspectScanJob.created_at.desc())
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
.all()
|
||||
)
|
||||
return jobs, total
|
||||
|
||||
def _get_common_issues(self, db: Session) -> list[dict]:
|
||||
"""Extract common issue flags from scored prospects."""
|
||||
scores = db.query(ProspectScore.reason_flags).filter(ProspectScore.reason_flags.isnot(None)).all()
|
||||
|
||||
import json
|
||||
flag_counts: dict[str, int] = {}
|
||||
for (flags_json,) in scores:
|
||||
try:
|
||||
flags = json.loads(flags_json)
|
||||
for flag in flags:
|
||||
flag_counts[flag] = flag_counts.get(flag, 0) + 1
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
sorted_flags = sorted(flag_counts.items(), key=lambda x: x[1], reverse=True)
|
||||
return [{"flag": flag, "count": count} for flag, count in sorted_flags[:10]]
|
||||
|
||||
|
||||
stats_service = StatsService()
|
||||
137
app/modules/prospecting/static/admin/js/campaigns.js
Normal file
137
app/modules/prospecting/static/admin/js/campaigns.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// static/admin/js/campaigns.js
|
||||
|
||||
const campLog = window.LogConfig.createLogger('prospecting-campaigns');
|
||||
|
||||
function campaignManager() {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
currentPage: 'campaigns',
|
||||
|
||||
templates: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
filterLeadType: '',
|
||||
|
||||
showCreateModal: false,
|
||||
showEditModal: false,
|
||||
editingTemplateId: null,
|
||||
|
||||
templateForm: {
|
||||
name: '',
|
||||
lead_type: 'no_website',
|
||||
channel: 'email',
|
||||
language: 'fr',
|
||||
subject_template: '',
|
||||
body_template: '',
|
||||
is_active: true,
|
||||
},
|
||||
|
||||
leadTypes: [
|
||||
{ value: 'no_website', label: 'No Website' },
|
||||
{ value: 'bad_website', label: 'Bad Website' },
|
||||
{ value: 'gmail_only', label: 'Gmail Only' },
|
||||
{ value: 'security_issues', label: 'Security Issues' },
|
||||
{ value: 'performance_issues', label: 'Performance Issues' },
|
||||
{ value: 'outdated_cms', label: 'Outdated CMS' },
|
||||
{ value: 'general', label: 'General' },
|
||||
],
|
||||
|
||||
placeholders: [
|
||||
'{business_name}', '{domain}', '{score}', '{issues}',
|
||||
'{primary_email}', '{primary_phone}', '{city}',
|
||||
],
|
||||
|
||||
async init() {
|
||||
await I18n.loadModule('prospecting');
|
||||
|
||||
if (window._campaignsInit) return;
|
||||
window._campaignsInit = true;
|
||||
|
||||
campLog.info('Campaign manager initializing');
|
||||
await this.loadTemplates();
|
||||
},
|
||||
|
||||
async loadTemplates() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await apiClient.get('/admin/prospecting/campaigns/templates');
|
||||
this.templates = response.items || response || [];
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
campLog.error('Failed to load templates', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
filteredTemplates() {
|
||||
if (!this.filterLeadType) return this.templates;
|
||||
return this.templates.filter(t => t.lead_type === this.filterLeadType);
|
||||
},
|
||||
|
||||
editTemplate(tpl) {
|
||||
this.editingTemplateId = tpl.id;
|
||||
this.templateForm = {
|
||||
name: tpl.name,
|
||||
lead_type: tpl.lead_type,
|
||||
channel: tpl.channel,
|
||||
language: tpl.language,
|
||||
subject_template: tpl.subject_template || '',
|
||||
body_template: tpl.body_template,
|
||||
is_active: tpl.is_active,
|
||||
};
|
||||
this.showEditModal = true;
|
||||
},
|
||||
|
||||
async saveTemplate() {
|
||||
try {
|
||||
if (this.showEditModal && this.editingTemplateId) {
|
||||
await apiClient.put(
|
||||
'/admin/prospecting/campaigns/templates/' + this.editingTemplateId,
|
||||
this.templateForm,
|
||||
);
|
||||
Utils.showToast('Template updated', 'success');
|
||||
} else {
|
||||
await apiClient.post('/admin/prospecting/campaigns/templates', this.templateForm);
|
||||
Utils.showToast('Template created', 'success');
|
||||
}
|
||||
this.showCreateModal = false;
|
||||
this.showEditModal = false;
|
||||
this.resetForm();
|
||||
await this.loadTemplates();
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteTemplate(id) {
|
||||
if (!confirm('Delete this template?')) return;
|
||||
try {
|
||||
await apiClient.delete('/admin/prospecting/campaigns/templates/' + id);
|
||||
Utils.showToast('Template deleted', 'success');
|
||||
await this.loadTemplates();
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
insertPlaceholder(ph) {
|
||||
this.templateForm.body_template += ph;
|
||||
},
|
||||
|
||||
resetForm() {
|
||||
this.editingTemplateId = null;
|
||||
this.templateForm = {
|
||||
name: '',
|
||||
lead_type: 'no_website',
|
||||
channel: 'email',
|
||||
language: 'fr',
|
||||
subject_template: '',
|
||||
body_template: '',
|
||||
is_active: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
153
app/modules/prospecting/static/admin/js/capture.js
Normal file
153
app/modules/prospecting/static/admin/js/capture.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// noqa: js-006 - async init pattern is safe, no data loading
|
||||
// static/admin/js/capture.js
|
||||
|
||||
const captureLog = window.LogConfig.createLogger('prospecting-capture');
|
||||
|
||||
function quickCapture() {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
currentPage: 'capture',
|
||||
|
||||
form: {
|
||||
business_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
city: '',
|
||||
postal_code: '',
|
||||
source: 'street',
|
||||
notes: '',
|
||||
tags: [],
|
||||
location_lat: null,
|
||||
location_lng: null,
|
||||
},
|
||||
|
||||
sources: [
|
||||
{ value: 'street', label: 'Street' },
|
||||
{ value: 'networking_event', label: 'Networking' },
|
||||
{ value: 'referral', label: 'Referral' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
],
|
||||
|
||||
availableTags: [
|
||||
'no-website', 'gmail', 'restaurant', 'retail', 'services',
|
||||
'professional', 'construction', 'beauty', 'food',
|
||||
],
|
||||
|
||||
submitting: false,
|
||||
saved: false,
|
||||
lastSaved: '',
|
||||
gettingLocation: false,
|
||||
recentCaptures: [],
|
||||
|
||||
async init() {
|
||||
await I18n.loadModule('prospecting');
|
||||
|
||||
if (window._captureInit) return;
|
||||
window._captureInit = true;
|
||||
|
||||
captureLog.info('Quick capture initializing');
|
||||
},
|
||||
|
||||
toggleTag(tag) {
|
||||
const idx = this.form.tags.indexOf(tag);
|
||||
if (idx >= 0) {
|
||||
this.form.tags.splice(idx, 1);
|
||||
} else {
|
||||
this.form.tags.push(tag);
|
||||
}
|
||||
},
|
||||
|
||||
getLocation() {
|
||||
if (!navigator.geolocation) {
|
||||
Utils.showToast('Geolocation not supported', 'error');
|
||||
return;
|
||||
}
|
||||
this.gettingLocation = true;
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
this.form.location_lat = pos.coords.latitude;
|
||||
this.form.location_lng = pos.coords.longitude;
|
||||
this.gettingLocation = false;
|
||||
captureLog.info('Location acquired', pos.coords);
|
||||
},
|
||||
(err) => {
|
||||
this.gettingLocation = false;
|
||||
Utils.showToast('Location error: ' + err.message, 'error');
|
||||
captureLog.error('Geolocation error', err);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 },
|
||||
);
|
||||
},
|
||||
|
||||
async submitCapture() {
|
||||
if (!this.form.business_name) {
|
||||
Utils.showToast('Business name is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
try {
|
||||
const payload = {
|
||||
channel: 'offline',
|
||||
business_name: this.form.business_name,
|
||||
source: this.form.source,
|
||||
notes: this.form.notes,
|
||||
tags: this.form.tags.length > 0 ? this.form.tags : null,
|
||||
address: this.form.address || null,
|
||||
city: this.form.city || null,
|
||||
postal_code: this.form.postal_code || null,
|
||||
location_lat: this.form.location_lat,
|
||||
location_lng: this.form.location_lng,
|
||||
contacts: [],
|
||||
};
|
||||
|
||||
if (this.form.phone) {
|
||||
payload.contacts.push({ contact_type: 'phone', value: this.form.phone });
|
||||
}
|
||||
if (this.form.email) {
|
||||
payload.contacts.push({ contact_type: 'email', value: this.form.email });
|
||||
}
|
||||
|
||||
const result = await apiClient.post('/admin/prospecting/prospects', payload);
|
||||
|
||||
this.lastSaved = this.form.business_name;
|
||||
this.recentCaptures.unshift({
|
||||
id: result.id,
|
||||
business_name: this.form.business_name,
|
||||
city: this.form.city,
|
||||
source: this.form.source,
|
||||
});
|
||||
|
||||
this.saved = true;
|
||||
setTimeout(() => { this.saved = false; }, 3000);
|
||||
|
||||
this.resetForm();
|
||||
Utils.showToast('Prospect saved!', 'success');
|
||||
captureLog.info('Capture saved', result.id);
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
captureLog.error('Capture failed', err);
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
resetForm() {
|
||||
this.form = {
|
||||
business_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
city: this.form.city, // Keep city for rapid captures in same area
|
||||
postal_code: this.form.postal_code,
|
||||
source: this.form.source,
|
||||
notes: '',
|
||||
tags: [],
|
||||
location_lat: this.form.location_lat, // Keep GPS
|
||||
location_lng: this.form.location_lng,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
84
app/modules/prospecting/static/admin/js/dashboard.js
Normal file
84
app/modules/prospecting/static/admin/js/dashboard.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// static/admin/js/dashboard.js
|
||||
|
||||
const dashLog = window.LogConfig.createLogger('prospecting-dashboard');
|
||||
|
||||
function prospectingDashboard() {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
currentPage: 'prospecting-dashboard',
|
||||
|
||||
stats: {
|
||||
total_prospects: 0,
|
||||
digital_count: 0,
|
||||
offline_count: 0,
|
||||
top_priority: 0,
|
||||
avg_score: null,
|
||||
leads_by_tier: {},
|
||||
common_issues: [],
|
||||
},
|
||||
loading: true,
|
||||
error: null,
|
||||
showImportModal: false,
|
||||
|
||||
async init() {
|
||||
await I18n.loadModule('prospecting');
|
||||
|
||||
if (window._prospectingDashboardInit) return;
|
||||
window._prospectingDashboardInit = true;
|
||||
|
||||
dashLog.info('Dashboard initializing');
|
||||
await this.loadStats();
|
||||
},
|
||||
|
||||
async loadStats() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await apiClient.get('/admin/prospecting/stats');
|
||||
Object.assign(this.stats, response);
|
||||
dashLog.info('Stats loaded', this.stats);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
dashLog.error('Failed to load stats', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async runBatchScan() {
|
||||
try {
|
||||
await apiClient.post('/admin/prospecting/enrichment/http-check/batch');
|
||||
Utils.showToast('Batch scan started', 'success');
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed to start batch scan: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
tierBadgeClass(tier) {
|
||||
const classes = {
|
||||
top_priority: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
|
||||
quick_win: 'text-orange-700 bg-orange-100 dark:text-orange-100 dark:bg-orange-700',
|
||||
strategic: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
|
||||
low_priority: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700',
|
||||
};
|
||||
return classes[tier] || classes.low_priority;
|
||||
},
|
||||
|
||||
issueLabel(flag) {
|
||||
const labels = {
|
||||
no_ssl: 'No SSL/HTTPS',
|
||||
very_slow: 'Very Slow',
|
||||
slow: 'Slow',
|
||||
not_mobile_friendly: 'Not Mobile Friendly',
|
||||
outdated_cms: 'Outdated CMS',
|
||||
unknown_cms: 'Unknown CMS',
|
||||
legacy_js: 'Legacy JavaScript',
|
||||
no_analytics: 'No Analytics',
|
||||
no_website: 'No Website',
|
||||
uses_gmail: 'Uses Gmail',
|
||||
};
|
||||
return labels[flag] || flag.replace(/_/g, ' ');
|
||||
},
|
||||
};
|
||||
}
|
||||
107
app/modules/prospecting/static/admin/js/leads.js
Normal file
107
app/modules/prospecting/static/admin/js/leads.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// static/admin/js/leads.js
|
||||
|
||||
const leadsLog = window.LogConfig.createLogger('prospecting-leads');
|
||||
|
||||
function leadsList() {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
currentPage: 'leads',
|
||||
|
||||
leads: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
// Filters
|
||||
minScore: 0,
|
||||
filterTier: '',
|
||||
filterChannel: '',
|
||||
filterIssue: '',
|
||||
filterHasEmail: '',
|
||||
|
||||
// Pagination
|
||||
pagination: { page: 1, per_page: 20, total: 0, pages: 0 },
|
||||
|
||||
async init() {
|
||||
await I18n.loadModule('prospecting');
|
||||
|
||||
if (window._leadsListInit) return;
|
||||
window._leadsListInit = true;
|
||||
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
leadsLog.info('Leads list initializing');
|
||||
await this.loadLeads();
|
||||
},
|
||||
|
||||
async loadLeads() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.pagination.page,
|
||||
per_page: this.pagination.per_page,
|
||||
});
|
||||
if (this.minScore > 0) params.set('min_score', this.minScore);
|
||||
if (this.filterTier) params.set('lead_tier', this.filterTier);
|
||||
if (this.filterChannel) params.set('channel', this.filterChannel);
|
||||
if (this.filterIssue) params.set('reason_flag', this.filterIssue);
|
||||
if (this.filterHasEmail) params.set('has_email', this.filterHasEmail);
|
||||
|
||||
const response = await apiClient.get('/admin/prospecting/leads?' + params);
|
||||
this.leads = response.items || [];
|
||||
this.pagination.total = response.total || 0;
|
||||
this.pagination.pages = response.pages || 0;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
leadsLog.error('Failed to load leads', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async exportCSV() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (this.minScore > 0) params.set('min_score', this.minScore);
|
||||
if (this.filterTier) params.set('lead_tier', this.filterTier);
|
||||
if (this.filterChannel) params.set('channel', this.filterChannel);
|
||||
|
||||
const url = '/admin/prospecting/leads/export/csv?' + params;
|
||||
window.open('/api/v1' + url, '_blank');
|
||||
Utils.showToast('CSV export started', 'success');
|
||||
} catch (err) {
|
||||
Utils.showToast('Export failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
sendCampaign(lead) {
|
||||
window.location.href = '/admin/prospecting/prospects/' + lead.id + '#campaigns';
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
this.pagination.page = page;
|
||||
this.loadLeads();
|
||||
},
|
||||
|
||||
tierBadgeClass(tier) {
|
||||
const classes = {
|
||||
top_priority: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
|
||||
quick_win: 'text-orange-700 bg-orange-100 dark:text-orange-100 dark:bg-orange-700',
|
||||
strategic: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
|
||||
low_priority: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700',
|
||||
};
|
||||
return classes[tier] || classes.low_priority;
|
||||
},
|
||||
|
||||
scoreColor(score) {
|
||||
if (score == null) return 'text-gray-400';
|
||||
if (score >= 70) return 'text-red-600';
|
||||
if (score >= 50) return 'text-orange-600';
|
||||
if (score >= 30) return 'text-blue-600';
|
||||
return 'text-gray-600';
|
||||
},
|
||||
};
|
||||
}
|
||||
141
app/modules/prospecting/static/admin/js/prospect-detail.js
Normal file
141
app/modules/prospecting/static/admin/js/prospect-detail.js
Normal file
@@ -0,0 +1,141 @@
|
||||
// static/admin/js/prospect-detail.js
|
||||
|
||||
const detailLog = window.LogConfig.createLogger('prospecting-detail');
|
||||
|
||||
function prospectDetail(prospectId) {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
currentPage: 'prospects',
|
||||
prospectId: prospectId,
|
||||
prospect: null,
|
||||
interactions: [],
|
||||
campaignSends: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
activeTab: 'overview',
|
||||
tabs: [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'interactions', label: 'Interactions' },
|
||||
{ id: 'campaigns', label: 'Campaigns' },
|
||||
],
|
||||
|
||||
// Interaction modal
|
||||
showInteractionModal: false,
|
||||
newInteraction: {
|
||||
interaction_type: 'note',
|
||||
subject: '',
|
||||
notes: '',
|
||||
outcome: '',
|
||||
next_action: '',
|
||||
},
|
||||
|
||||
// Campaign modal
|
||||
showSendCampaignModal: false,
|
||||
|
||||
async init() {
|
||||
await I18n.loadModule('prospecting');
|
||||
|
||||
if (window._prospectDetailInit) return;
|
||||
window._prospectDetailInit = true;
|
||||
|
||||
detailLog.info('Prospect detail initializing for ID:', this.prospectId);
|
||||
await this.loadProspect();
|
||||
},
|
||||
|
||||
async loadProspect() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
this.prospect = await apiClient.get('/admin/prospecting/prospects/' + this.prospectId);
|
||||
await Promise.all([
|
||||
this.loadInteractions(),
|
||||
this.loadCampaignSends(),
|
||||
]);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
detailLog.error('Failed to load prospect', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadInteractions() {
|
||||
try {
|
||||
const resp = await apiClient.get('/admin/prospecting/prospects/' + this.prospectId + '/interactions');
|
||||
this.interactions = resp.items || resp || [];
|
||||
} catch (err) {
|
||||
detailLog.warn('Failed to load interactions', err);
|
||||
}
|
||||
},
|
||||
|
||||
async loadCampaignSends() {
|
||||
try {
|
||||
const resp = await apiClient.get('/admin/prospecting/campaigns/sends?prospect_id=' + this.prospectId);
|
||||
this.campaignSends = resp.items || resp || [];
|
||||
} catch (err) {
|
||||
detailLog.warn('Failed to load campaign sends', err);
|
||||
}
|
||||
},
|
||||
|
||||
async updateStatus() {
|
||||
try {
|
||||
await apiClient.put('/admin/prospecting/prospects/' + this.prospectId, {
|
||||
status: this.prospect.status,
|
||||
});
|
||||
Utils.showToast('Status updated', 'success');
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async runEnrichment() {
|
||||
try {
|
||||
await apiClient.post('/admin/prospecting/enrichment/full/' + this.prospectId);
|
||||
Utils.showToast('Enrichment scan started', 'success');
|
||||
setTimeout(() => this.loadProspect(), 5000);
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async createInteraction() {
|
||||
try {
|
||||
await apiClient.post(
|
||||
'/admin/prospecting/prospects/' + this.prospectId + '/interactions',
|
||||
this.newInteraction,
|
||||
);
|
||||
Utils.showToast('Interaction logged', 'success');
|
||||
this.showInteractionModal = false;
|
||||
this.newInteraction = { interaction_type: 'note', subject: '', notes: '', outcome: '', next_action: '' };
|
||||
await this.loadInteractions();
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
scoreColor(score) {
|
||||
if (score == null) return 'text-gray-400';
|
||||
if (score >= 70) return 'text-red-600';
|
||||
if (score >= 50) return 'text-orange-600';
|
||||
if (score >= 30) return 'text-blue-600';
|
||||
return 'text-gray-600';
|
||||
},
|
||||
|
||||
techProfileEntries() {
|
||||
const tp = this.prospect?.tech_profile;
|
||||
if (!tp) return [];
|
||||
const entries = [];
|
||||
if (tp.cms) entries.push(['CMS', tp.cms + (tp.cms_version ? ' ' + tp.cms_version : '')]);
|
||||
if (tp.server) entries.push(['Server', tp.server]);
|
||||
if (tp.js_framework) entries.push(['JS Framework', tp.js_framework]);
|
||||
if (tp.analytics) entries.push(['Analytics', tp.analytics]);
|
||||
if (tp.ecommerce_platform) entries.push(['E-commerce', tp.ecommerce_platform]);
|
||||
if (tp.hosting_provider) entries.push(['Hosting', tp.hosting_provider]);
|
||||
if (tp.cdn) entries.push(['CDN', tp.cdn]);
|
||||
entries.push(['SSL Valid', tp.has_valid_cert ? 'Yes' : 'No']);
|
||||
return entries;
|
||||
},
|
||||
};
|
||||
}
|
||||
149
app/modules/prospecting/static/admin/js/prospects.js
Normal file
149
app/modules/prospecting/static/admin/js/prospects.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// static/admin/js/prospects.js
|
||||
|
||||
const prospectsLog = window.LogConfig.createLogger('prospecting-prospects');
|
||||
|
||||
function prospectsList() {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
currentPage: 'prospects',
|
||||
|
||||
prospects: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
// Filters
|
||||
search: '',
|
||||
filterChannel: '',
|
||||
filterStatus: '',
|
||||
filterTier: '',
|
||||
|
||||
// Pagination
|
||||
pagination: { page: 1, per_page: 20, total: 0, pages: 0 },
|
||||
|
||||
// Create modal
|
||||
showCreateModal: false,
|
||||
creating: false,
|
||||
newProspect: {
|
||||
channel: 'digital',
|
||||
domain_name: '',
|
||||
business_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
city: '',
|
||||
source: 'street',
|
||||
notes: '',
|
||||
},
|
||||
|
||||
async init() {
|
||||
await I18n.loadModule('prospecting');
|
||||
|
||||
if (window._prospectsListInit) return;
|
||||
window._prospectsListInit = true;
|
||||
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
prospectsLog.info('Prospects list initializing');
|
||||
await this.loadProspects();
|
||||
},
|
||||
|
||||
async loadProspects() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.pagination.page,
|
||||
per_page: this.pagination.per_page,
|
||||
});
|
||||
if (this.search) params.set('search', this.search);
|
||||
if (this.filterChannel) params.set('channel', this.filterChannel);
|
||||
if (this.filterStatus) params.set('status', this.filterStatus);
|
||||
if (this.filterTier) params.set('tier', this.filterTier);
|
||||
|
||||
const response = await apiClient.get('/admin/prospecting/prospects?' + params);
|
||||
this.prospects = response.items || [];
|
||||
this.pagination.total = response.total || 0;
|
||||
this.pagination.pages = response.pages || 0;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
prospectsLog.error('Failed to load prospects', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async createProspect() {
|
||||
this.creating = true;
|
||||
try {
|
||||
const payload = { ...this.newProspect };
|
||||
if (payload.channel === 'digital') {
|
||||
delete payload.business_name;
|
||||
delete payload.phone;
|
||||
delete payload.email;
|
||||
delete payload.city;
|
||||
} else {
|
||||
delete payload.domain_name;
|
||||
}
|
||||
await apiClient.post('/admin/prospecting/prospects', payload);
|
||||
Utils.showToast('Prospect created', 'success');
|
||||
this.showCreateModal = false;
|
||||
this.resetNewProspect();
|
||||
await this.loadProspects();
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
|
||||
resetNewProspect() {
|
||||
this.newProspect = {
|
||||
channel: 'digital',
|
||||
domain_name: '',
|
||||
business_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
city: '',
|
||||
source: 'street',
|
||||
notes: '',
|
||||
};
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
this.pagination.page = page;
|
||||
this.loadProspects();
|
||||
},
|
||||
|
||||
statusBadgeClass(status) {
|
||||
const classes = {
|
||||
pending: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
|
||||
active: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
|
||||
contacted: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
|
||||
converted: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
|
||||
inactive: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700',
|
||||
error: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
|
||||
};
|
||||
return classes[status] || classes.pending;
|
||||
},
|
||||
|
||||
tierBadgeClass(tier) {
|
||||
const classes = {
|
||||
top_priority: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
|
||||
quick_win: 'text-orange-700 bg-orange-100 dark:text-orange-100 dark:bg-orange-700',
|
||||
strategic: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
|
||||
low_priority: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700',
|
||||
};
|
||||
return classes[tier] || classes.low_priority;
|
||||
},
|
||||
|
||||
scoreColor(score) {
|
||||
if (score == null) return 'text-gray-400';
|
||||
if (score >= 70) return 'text-red-600';
|
||||
if (score >= 50) return 'text-orange-600';
|
||||
if (score >= 30) return 'text-blue-600';
|
||||
return 'text-gray-600';
|
||||
},
|
||||
};
|
||||
}
|
||||
87
app/modules/prospecting/static/admin/js/scan-jobs.js
Normal file
87
app/modules/prospecting/static/admin/js/scan-jobs.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// static/admin/js/scan-jobs.js
|
||||
|
||||
const jobsLog = window.LogConfig.createLogger('prospecting-scan-jobs');
|
||||
|
||||
function scanJobs() {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
currentPage: 'scan-jobs',
|
||||
|
||||
jobs: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
pagination: { page: 1, per_page: 20, total: 0, pages: 0 },
|
||||
|
||||
async init() {
|
||||
await I18n.loadModule('prospecting');
|
||||
|
||||
if (window._scanJobsInit) return;
|
||||
window._scanJobsInit = true;
|
||||
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
jobsLog.info('Scan jobs initializing');
|
||||
await this.loadJobs();
|
||||
},
|
||||
|
||||
async loadJobs() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.pagination.page,
|
||||
per_page: this.pagination.per_page,
|
||||
});
|
||||
const response = await apiClient.get('/admin/prospecting/stats/jobs?' + params);
|
||||
this.jobs = response.items || [];
|
||||
this.pagination.total = response.total || 0;
|
||||
this.pagination.pages = response.pages || 0;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
jobsLog.error('Failed to load jobs', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async startBatchJob(jobType) {
|
||||
try {
|
||||
await apiClient.post('/admin/prospecting/enrichment/' + jobType.replace('_', '-') + '/batch');
|
||||
Utils.showToast(jobType.replace(/_/g, ' ') + ' batch started', 'success');
|
||||
setTimeout(() => this.loadJobs(), 2000);
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
this.pagination.page = page;
|
||||
this.loadJobs();
|
||||
},
|
||||
|
||||
jobStatusClass(status) {
|
||||
const classes = {
|
||||
pending: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
|
||||
running: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
|
||||
completed: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
|
||||
failed: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
|
||||
cancelled: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700',
|
||||
};
|
||||
return classes[status] || classes.pending;
|
||||
},
|
||||
|
||||
formatDuration(job) {
|
||||
if (!job.started_at) return '—';
|
||||
const start = new Date(job.started_at);
|
||||
const end = job.completed_at ? new Date(job.completed_at) : new Date();
|
||||
const seconds = Math.round((end - start) / 1000);
|
||||
if (seconds < 60) return seconds + 's';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return mins + 'm ' + secs + 's';
|
||||
},
|
||||
};
|
||||
}
|
||||
1
app/modules/prospecting/tasks/__init__.py
Normal file
1
app/modules/prospecting/tasks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/prospecting/tasks/__init__.py
|
||||
325
app/modules/prospecting/tasks/scan_tasks.py
Normal file
325
app/modules/prospecting/tasks/scan_tasks.py
Normal file
@@ -0,0 +1,325 @@
|
||||
# app/modules/prospecting/tasks/scan_tasks.py
|
||||
"""
|
||||
Celery tasks for batch prospect scanning and enrichment.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.core.celery_config import celery_app
|
||||
from app.modules.prospecting.models import ProspectScanJob
|
||||
from app.modules.task_base import ModuleTask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
base=ModuleTask,
|
||||
name="app.modules.prospecting.tasks.scan_tasks.batch_http_check",
|
||||
max_retries=2,
|
||||
default_retry_delay=60,
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
retry_backoff_max=300,
|
||||
)
|
||||
def batch_http_check(self, job_id: int, limit: int = 100):
|
||||
"""Run HTTP connectivity check for pending prospects."""
|
||||
with self.get_db() as db:
|
||||
job = db.query(ProspectScanJob).filter(ProspectScanJob.id == job_id).first()
|
||||
if not job:
|
||||
logger.error("Scan job %d not found", job_id)
|
||||
return
|
||||
|
||||
job.celery_task_id = self.request.id
|
||||
job.status = "running"
|
||||
job.started_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
|
||||
try:
|
||||
from app.modules.prospecting.services.enrichment_service import (
|
||||
enrichment_service,
|
||||
)
|
||||
from app.modules.prospecting.services.prospect_service import (
|
||||
prospect_service,
|
||||
)
|
||||
|
||||
prospects = prospect_service.get_pending_http_check(db, limit=limit)
|
||||
job.total_items = len(prospects)
|
||||
db.flush()
|
||||
|
||||
for processed, prospect in enumerate(prospects, 1):
|
||||
enrichment_service.check_http(db, prospect)
|
||||
job.processed_items = processed
|
||||
if processed % 10 == 0:
|
||||
db.flush()
|
||||
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.now(UTC)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("batch_http_check job %d failed: %s", job_id, e, exc_info=True)
|
||||
job.status = "failed"
|
||||
job.error_message = str(e)[:500]
|
||||
job.completed_at = datetime.now(UTC)
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
base=ModuleTask,
|
||||
name="app.modules.prospecting.tasks.scan_tasks.batch_tech_scan",
|
||||
max_retries=2,
|
||||
default_retry_delay=60,
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
retry_backoff_max=300,
|
||||
)
|
||||
def batch_tech_scan(self, job_id: int, limit: int = 100):
|
||||
"""Run technology scan for pending prospects."""
|
||||
with self.get_db() as db:
|
||||
job = db.query(ProspectScanJob).filter(ProspectScanJob.id == job_id).first()
|
||||
if not job:
|
||||
logger.error("Scan job %d not found", job_id)
|
||||
return
|
||||
|
||||
job.celery_task_id = self.request.id
|
||||
job.status = "running"
|
||||
job.started_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
|
||||
try:
|
||||
from app.modules.prospecting.services.enrichment_service import (
|
||||
enrichment_service,
|
||||
)
|
||||
from app.modules.prospecting.services.prospect_service import (
|
||||
prospect_service,
|
||||
)
|
||||
|
||||
prospects = prospect_service.get_pending_tech_scan(db, limit=limit)
|
||||
job.total_items = len(prospects)
|
||||
db.flush()
|
||||
|
||||
successful = 0
|
||||
for processed, prospect in enumerate(prospects, 1):
|
||||
result = enrichment_service.scan_tech_stack(db, prospect)
|
||||
if result:
|
||||
successful += 1
|
||||
job.processed_items = processed
|
||||
if processed % 10 == 0:
|
||||
db.flush()
|
||||
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.now(UTC)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("batch_tech_scan job %d failed: %s", job_id, e, exc_info=True)
|
||||
job.status = "failed"
|
||||
job.error_message = str(e)[:500]
|
||||
job.completed_at = datetime.now(UTC)
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
base=ModuleTask,
|
||||
name="app.modules.prospecting.tasks.scan_tasks.batch_performance_scan",
|
||||
max_retries=2,
|
||||
default_retry_delay=120,
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
retry_backoff_max=600,
|
||||
)
|
||||
def batch_performance_scan(self, job_id: int, limit: int = 50):
|
||||
"""Run PageSpeed performance scan for pending prospects."""
|
||||
with self.get_db() as db:
|
||||
job = db.query(ProspectScanJob).filter(ProspectScanJob.id == job_id).first()
|
||||
if not job:
|
||||
logger.error("Scan job %d not found", job_id)
|
||||
return
|
||||
|
||||
job.celery_task_id = self.request.id
|
||||
job.status = "running"
|
||||
job.started_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
|
||||
try:
|
||||
from app.modules.prospecting.services.enrichment_service import (
|
||||
enrichment_service,
|
||||
)
|
||||
from app.modules.prospecting.services.prospect_service import (
|
||||
prospect_service,
|
||||
)
|
||||
|
||||
prospects = prospect_service.get_pending_performance_scan(db, limit=limit)
|
||||
job.total_items = len(prospects)
|
||||
db.flush()
|
||||
|
||||
successful = 0
|
||||
for processed, prospect in enumerate(prospects, 1):
|
||||
result = enrichment_service.scan_performance(db, prospect)
|
||||
if result:
|
||||
successful += 1
|
||||
job.processed_items = processed
|
||||
if processed % 5 == 0:
|
||||
db.flush()
|
||||
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.now(UTC)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("batch_performance_scan job %d failed: %s", job_id, e, exc_info=True)
|
||||
job.status = "failed"
|
||||
job.error_message = str(e)[:500]
|
||||
job.completed_at = datetime.now(UTC)
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
base=ModuleTask,
|
||||
name="app.modules.prospecting.tasks.scan_tasks.batch_contact_scrape",
|
||||
max_retries=2,
|
||||
default_retry_delay=60,
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
retry_backoff_max=300,
|
||||
)
|
||||
def batch_contact_scrape(self, job_id: int, limit: int = 100):
|
||||
"""Scrape contacts for pending prospects."""
|
||||
with self.get_db() as db:
|
||||
job = db.query(ProspectScanJob).filter(ProspectScanJob.id == job_id).first()
|
||||
if not job:
|
||||
logger.error("Scan job %d not found", job_id)
|
||||
return
|
||||
|
||||
job.celery_task_id = self.request.id
|
||||
job.status = "running"
|
||||
job.started_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
|
||||
try:
|
||||
from app.modules.prospecting.services.enrichment_service import (
|
||||
enrichment_service,
|
||||
)
|
||||
from app.modules.prospecting.services.prospect_service import (
|
||||
prospect_service,
|
||||
)
|
||||
|
||||
prospects = prospect_service.get_pending_http_check(db, limit=limit)
|
||||
# Only scrape those with websites
|
||||
prospects = [p for p in prospects if p.has_website]
|
||||
job.total_items = len(prospects)
|
||||
db.flush()
|
||||
|
||||
for processed, prospect in enumerate(prospects, 1):
|
||||
enrichment_service.scrape_contacts(db, prospect)
|
||||
job.processed_items = processed
|
||||
if processed % 10 == 0:
|
||||
db.flush()
|
||||
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.now(UTC)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("batch_contact_scrape job %d failed: %s", job_id, e, exc_info=True)
|
||||
job.status = "failed"
|
||||
job.error_message = str(e)[:500]
|
||||
job.completed_at = datetime.now(UTC)
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
base=ModuleTask,
|
||||
name="app.modules.prospecting.tasks.scan_tasks.batch_score_compute",
|
||||
max_retries=1,
|
||||
default_retry_delay=30,
|
||||
)
|
||||
def batch_score_compute(self, job_id: int, limit: int = 500):
|
||||
"""Compute or recompute scores for all prospects."""
|
||||
with self.get_db() as db:
|
||||
job = db.query(ProspectScanJob).filter(ProspectScanJob.id == job_id).first()
|
||||
if not job:
|
||||
logger.error("Scan job %d not found", job_id)
|
||||
return
|
||||
|
||||
job.celery_task_id = self.request.id
|
||||
job.status = "running"
|
||||
job.started_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
|
||||
try:
|
||||
from app.modules.prospecting.services.scoring_service import scoring_service
|
||||
|
||||
count = scoring_service.compute_all(db, limit=limit)
|
||||
job.processed_items = count
|
||||
job.total_items = count
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.now(UTC)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("batch_score_compute job %d failed: %s", job_id, e, exc_info=True)
|
||||
job.status = "failed"
|
||||
job.error_message = str(e)[:500]
|
||||
job.completed_at = datetime.now(UTC)
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
base=ModuleTask,
|
||||
name="app.modules.prospecting.tasks.scan_tasks.full_enrichment",
|
||||
max_retries=2,
|
||||
default_retry_delay=60,
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
)
|
||||
def full_enrichment(self, job_id: int, prospect_id: int):
|
||||
"""Run full enrichment pipeline for a single prospect."""
|
||||
with self.get_db() as db:
|
||||
job = db.query(ProspectScanJob).filter(ProspectScanJob.id == job_id).first()
|
||||
if not job:
|
||||
logger.error("Scan job %d not found", job_id)
|
||||
return
|
||||
|
||||
job.celery_task_id = self.request.id
|
||||
job.status = "running"
|
||||
job.started_at = datetime.now(UTC)
|
||||
job.total_items = 1
|
||||
db.flush()
|
||||
|
||||
try:
|
||||
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
|
||||
|
||||
prospect = prospect_service.get_by_id(db, prospect_id)
|
||||
|
||||
# HTTP check
|
||||
enrichment_service.check_http(db, prospect)
|
||||
|
||||
# Tech + Performance + Contacts (if has website)
|
||||
if prospect.has_website:
|
||||
enrichment_service.scan_tech_stack(db, prospect)
|
||||
enrichment_service.scan_performance(db, prospect)
|
||||
enrichment_service.scrape_contacts(db, prospect)
|
||||
|
||||
# Score
|
||||
db.refresh(prospect)
|
||||
scoring_service.compute_score(db, prospect)
|
||||
|
||||
job.processed_items = 1
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.now(UTC)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("full_enrichment job %d failed: %s", job_id, e, exc_info=True)
|
||||
job.status = "failed"
|
||||
job.error_message = str(e)[:500]
|
||||
job.completed_at = datetime.now(UTC)
|
||||
raise
|
||||
@@ -0,0 +1,143 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
|
||||
{% block title %}Campaigns{% endblock %}
|
||||
|
||||
{% block alpine_data %}campaignManager(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Campaign Templates', action_label='New Template', action_onclick='showCreateModal = true', action_icon='plus') }}
|
||||
|
||||
<!-- Filter by Lead Type -->
|
||||
<div class="mb-6 flex flex-wrap gap-2">
|
||||
<button @click="filterLeadType = ''" :class="!filterLeadType ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
|
||||
class="px-3 py-1.5 text-sm rounded-lg">All</button>
|
||||
<template x-for="lt in leadTypes" :key="lt.value">
|
||||
<button @click="filterLeadType = lt.value"
|
||||
:class="filterLeadType === lt.value ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
|
||||
class="px-3 py-1.5 text-sm rounded-lg"
|
||||
x-text="lt.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{ loading_state('Loading templates...') }}
|
||||
{{ error_state('Error loading templates') }}
|
||||
|
||||
<!-- Templates Grid -->
|
||||
<div x-show="!loading && !error" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<template x-for="tpl in filteredTemplates()" :key="tpl.id">
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="tpl.name"></h3>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
||||
x-text="tpl.lead_type.replace('_', ' ')"></span>
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
|
||||
x-text="tpl.channel"></span>
|
||||
<span class="text-xs text-gray-400" x-text="tpl.language.toUpperCase()"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="w-2 h-2 rounded-full" :class="tpl.is_active ? 'bg-green-500' : 'bg-gray-400'"></span>
|
||||
</div>
|
||||
<p x-show="tpl.subject_template" class="text-xs text-gray-500 mb-2 truncate" x-text="'Subject: ' + tpl.subject_template"></p>
|
||||
<p class="text-xs text-gray-400 line-clamp-2" x-text="tpl.body_template"></p>
|
||||
<div class="flex justify-end mt-3 space-x-2">
|
||||
<button @click="editTemplate(tpl)" class="text-xs text-purple-600 hover:text-purple-900 dark:text-purple-400">Edit</button>
|
||||
<button @click="deleteTemplate(tpl.id)" class="text-xs text-red-600 hover:text-red-900 dark:text-red-400">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Template Modal -->
|
||||
<div x-show="showCreateModal || showEditModal" x-cloak
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
@click.self="showCreateModal = false; showEditModal = false">
|
||||
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||
@keydown.escape.window="showCreateModal = false; showEditModal = false">
|
||||
<header class="flex justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
|
||||
x-text="showEditModal ? 'Edit Template' : 'New Template'"></h3>
|
||||
<button @click="showCreateModal = false; showEditModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Name</label>
|
||||
<input type="text" x-model="templateForm.name"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Lead Type</label>
|
||||
<select x-model="templateForm.lead_type"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<template x-for="lt in leadTypes" :key="lt.value">
|
||||
<option :value="lt.value" x-text="lt.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Channel</label>
|
||||
<select x-model="templateForm.channel"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="email">Email</option>
|
||||
<option value="letter">Letter</option>
|
||||
<option value="phone_script">Phone Script</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Language</label>
|
||||
<select x-model="templateForm.language"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="fr">French</option>
|
||||
<option value="de">German</option>
|
||||
<option value="en">English</option>
|
||||
<option value="lb">Luxembourgish</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Subject</label>
|
||||
<input type="text" x-model="templateForm.subject_template"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Body</label>
|
||||
<textarea x-model="templateForm.body_template" rows="10"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 font-mono"></textarea>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<span class="text-xs text-gray-400">Placeholders:</span>
|
||||
<template x-for="ph in placeholders" :key="ph">
|
||||
<button @click="insertPlaceholder(ph)"
|
||||
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400"
|
||||
x-text="ph"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" x-model="templateForm.is_active" id="tpl-active"
|
||||
class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
|
||||
<label for="tpl-active" class="ml-2 text-sm text-gray-600 dark:text-gray-400">Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="flex justify-end mt-6 space-x-3">
|
||||
<button @click="showCreateModal = false; showEditModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="saveTemplate()"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
Save
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('prospecting_static', path='admin/js/campaigns.js') }}"></script>
|
||||
{% endblock %}
|
||||
137
app/modules/prospecting/templates/prospecting/admin/capture.html
Normal file
137
app/modules/prospecting/templates/prospecting/admin/capture.html
Normal file
@@ -0,0 +1,137 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state %}
|
||||
|
||||
{% block title %}Quick Capture{% endblock %}
|
||||
|
||||
{% block alpine_data %}quickCapture(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Quick Capture') }}
|
||||
|
||||
<div class="max-w-lg mx-auto">
|
||||
<!-- Success Message -->
|
||||
<div x-show="saved" x-transition
|
||||
class="mb-4 p-4 bg-green-100 text-green-700 rounded-lg dark:bg-green-900 dark:text-green-300">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 inline mr-2')"></span>
|
||||
<span x-text="'Prospect saved: ' + lastSaved"></span>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800 space-y-5">
|
||||
<!-- Business Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Business Name *</label>
|
||||
<input type="text" x-model="form.business_name" required autofocus
|
||||
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:ring-purple-300 py-3">
|
||||
</div>
|
||||
|
||||
<!-- Phone + Email Row -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Phone</label>
|
||||
<input type="tel" x-model="form.phone" placeholder="+352..."
|
||||
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
|
||||
<input type="email" x-model="form.email" placeholder="contact@..."
|
||||
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address</label>
|
||||
<input type="text" x-model="form.address"
|
||||
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
|
||||
</div>
|
||||
|
||||
<!-- City + Postal Code -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City</label>
|
||||
<input type="text" x-model="form.city" placeholder="Luxembourg"
|
||||
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code</label>
|
||||
<input type="text" x-model="form.postal_code"
|
||||
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Source</label>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<template x-for="src in sources" :key="src.value">
|
||||
<button @click="form.source = src.value"
|
||||
:class="form.source === src.value ? 'bg-purple-600 text-white border-purple-600' : 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600'"
|
||||
class="px-3 py-2 text-sm font-medium rounded-lg border"
|
||||
x-text="src.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tags</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="tag in availableTags" :key="tag">
|
||||
<button @click="toggleTag(tag)"
|
||||
:class="form.tags.includes(tag) ? 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-900 dark:text-purple-300' : 'bg-gray-100 text-gray-600 border-gray-200 dark:bg-gray-700 dark:text-gray-400'"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-full border"
|
||||
x-text="tag"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notes</label>
|
||||
<textarea x-model="form.notes" rows="3" placeholder="Quick notes..."
|
||||
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<button @click="getLocation()"
|
||||
:disabled="gettingLocation"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700 disabled:opacity-50">
|
||||
<span x-html="$icon('map-pin', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="gettingLocation ? 'Getting location...' : (form.location_lat ? 'Location saved' : 'Get Location')"></span>
|
||||
</button>
|
||||
<span x-show="form.location_lat" class="text-xs text-green-600">
|
||||
<span x-text="form.location_lat?.toFixed(4)"></span>, <span x-text="form.location_lng?.toFixed(4)"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<button @click="submitCapture()"
|
||||
:disabled="submitting || !form.business_name"
|
||||
class="w-full px-6 py-4 text-lg font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 active:bg-purple-800">
|
||||
<span x-show="!submitting">Save & Capture Next</span>
|
||||
<span x-show="submitting">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Recent Captures -->
|
||||
<div x-show="recentCaptures.length > 0" class="mt-6">
|
||||
<h3 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2">
|
||||
Recent Captures (<span x-text="recentCaptures.length"></span>)
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<template x-for="cap in recentCaptures" :key="cap.id">
|
||||
<div class="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm dark:bg-gray-800">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="cap.business_name"></span>
|
||||
<span class="text-xs text-gray-500" x-text="cap.city || cap.source"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('prospecting_static', path='admin/js/capture.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,127 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
|
||||
{% block title %}Prospecting Dashboard{% endblock %}
|
||||
|
||||
{% block alpine_data %}prospectingDashboard(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Prospecting Dashboard') }}
|
||||
{{ loading_state('Loading dashboard...') }}
|
||||
{{ error_state('Error loading dashboard') }}
|
||||
|
||||
<div x-show="!loading && !error" class="space-y-6">
|
||||
<!-- KPI Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('globe', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Prospects</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_prospects || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('target', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Top Priority</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.top_priority || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||
<span x-html="$icon('chart-bar', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Avg Score</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.avg_score ? stats.avg_score.toFixed(1) : '—'"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Offline Leads</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.offline_count || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
<a href="/admin/prospecting/capture"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
<span x-html="$icon('device-mobile', 'w-4 h-4 mr-2')"></span>
|
||||
Quick Capture
|
||||
</a>
|
||||
<button @click="runBatchScan()"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">
|
||||
<span x-html="$icon('radar', 'w-4 h-4 mr-2')"></span>
|
||||
Run Batch Scan
|
||||
</button>
|
||||
<button @click="showImportModal = true"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
|
||||
<span x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
|
||||
Import Domains
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Leads by Tier -->
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Leads by Tier</h3>
|
||||
<div class="space-y-3">
|
||||
<template x-for="tier in ['top_priority', 'quick_win', 'strategic', 'low_priority']" :key="tier">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 capitalize" x-text="tier.replace('_', ' ')"></span>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="tierBadgeClass(tier)"
|
||||
x-text="stats.leads_by_tier?.[tier] || 0"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">By Channel</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Digital (Domain Scan)</span>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="stats.digital_count || 0"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Offline (Captured)</span>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="stats.offline_count || 0"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Common Issues -->
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Common Issues</h3>
|
||||
<div class="space-y-2">
|
||||
<template x-for="issue in stats.common_issues || []" :key="issue.flag">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400" x-text="issueLabel(issue.flag)"></span>
|
||||
<div class="flex items-center">
|
||||
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700 mr-2">
|
||||
<div class="h-2 bg-red-500 rounded-full"
|
||||
:style="'width: ' + (issue.count / (stats.total_prospects || 1) * 100) + '%'"></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500" x-text="issue.count"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('prospecting_static', path='admin/js/dashboard.js') }}"></script>
|
||||
{% endblock %}
|
||||
127
app/modules/prospecting/templates/prospecting/admin/leads.html
Normal file
127
app/modules/prospecting/templates/prospecting/admin/leads.html
Normal file
@@ -0,0 +1,127 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/pagination.html' import pagination_controls %}
|
||||
|
||||
{% block title %}Leads{% endblock %}
|
||||
|
||||
{% block alpine_data %}leadsList(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Leads', action_label='Export CSV', action_onclick='exportCSV()', action_icon='download') }}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="grid gap-4 md:grid-cols-5">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Min Score</label>
|
||||
<input type="number" x-model.number="minScore" @change="loadLeads()" min="0" max="100"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Tier</label>
|
||||
<select x-model="filterTier" @change="loadLeads()"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">All</option>
|
||||
<option value="top_priority">Top Priority</option>
|
||||
<option value="quick_win">Quick Win</option>
|
||||
<option value="strategic">Strategic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Channel</label>
|
||||
<select x-model="filterChannel" @change="loadLeads()"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">All</option>
|
||||
<option value="digital">Digital</option>
|
||||
<option value="offline">Offline</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Issue</label>
|
||||
<select x-model="filterIssue" @change="loadLeads()"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">Any</option>
|
||||
<option value="no_ssl">No SSL</option>
|
||||
<option value="very_slow">Very Slow</option>
|
||||
<option value="not_mobile_friendly">Not Mobile Friendly</option>
|
||||
<option value="outdated_cms">Outdated CMS</option>
|
||||
<option value="no_website">No Website</option>
|
||||
<option value="uses_gmail">Uses Gmail</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Has Email</label>
|
||||
<select x-model="filterHasEmail" @change="loadLeads()"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">Any</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ loading_state('Loading leads...') }}
|
||||
{{ error_state('Error loading leads') }}
|
||||
|
||||
<!-- Leads Table -->
|
||||
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-nowrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Business / Domain</th>
|
||||
<th class="px-4 py-3">Score</th>
|
||||
<th class="px-4 py-3">Tier</th>
|
||||
<th class="px-4 py-3">Issues</th>
|
||||
<th class="px-4 py-3">Contact</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="lead in leads" :key="lead.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-semibold text-sm" x-text="lead.business_name || lead.domain_name"></p>
|
||||
<p class="text-xs text-gray-500" x-show="lead.domain_name && lead.business_name" x-text="lead.domain_name"></p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-lg font-bold" :class="scoreColor(lead.score)"
|
||||
x-text="lead.score"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="tierBadgeClass(lead.lead_tier)"
|
||||
x-text="lead.lead_tier?.replace('_', ' ')"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="flag in (lead.reason_flags || []).slice(0, 3)" :key="flag">
|
||||
<span class="px-1.5 py-0.5 text-xs bg-red-100 text-red-700 rounded dark:bg-red-900 dark:text-red-300"
|
||||
x-text="flag.replace('_', ' ')"></span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="lead.primary_email || lead.primary_phone || '—'" class="text-xs"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm space-x-2">
|
||||
<a :href="'/admin/prospecting/prospects/' + lead.id"
|
||||
class="text-purple-600 hover:text-purple-900 dark:text-purple-400">View</a>
|
||||
<button @click="sendCampaign(lead)"
|
||||
class="text-green-600 hover:text-green-900 dark:text-green-400">Campaign</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ pagination_controls() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('prospecting_static', path='admin/js/leads.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,280 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
|
||||
{% block title %}Prospect Detail{% endblock %}
|
||||
|
||||
{% block alpine_data %}prospectDetail({{ prospect_id }}){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ loading_state('Loading prospect...') }}
|
||||
{{ error_state('Error loading prospect') }}
|
||||
|
||||
<div x-show="!loading && !error && prospect" class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/admin/prospecting/prospects"
|
||||
class="p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<span x-html="$icon('arrow-left', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200"
|
||||
x-text="prospect.business_name || prospect.domain_name"></h2>
|
||||
<p class="text-sm text-gray-500" x-show="prospect.domain_name && prospect.business_name"
|
||||
x-text="prospect.domain_name"></p>
|
||||
</div>
|
||||
<span class="px-3 py-1 text-xs font-semibold rounded-full"
|
||||
:class="prospect.channel === 'digital' ? 'text-blue-700 bg-blue-100' : 'text-purple-700 bg-purple-100'"
|
||||
x-text="prospect.channel"></span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- Score Badge -->
|
||||
<div x-show="prospect.score" class="text-center">
|
||||
<div class="text-3xl font-bold" :class="scoreColor(prospect.score?.score)"
|
||||
x-text="prospect.score?.score"></div>
|
||||
<div class="text-xs text-gray-500 uppercase"
|
||||
x-text="prospect.score?.lead_tier?.replace('_', ' ')"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="flex items-center space-x-3 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Status:</label>
|
||||
<select x-model="prospect.status" @change="updateStatus()"
|
||||
class="text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="pending">Pending</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="contacted">Contacted</option>
|
||||
<option value="converted">Converted</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
<button @click="runEnrichment()" x-show="prospect.channel === 'digital'"
|
||||
class="ml-auto px-3 py-1 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700">
|
||||
<span x-html="$icon('radar', 'w-4 h-4 inline mr-1')"></span>
|
||||
Run Scan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="flex -mb-px space-x-8">
|
||||
<template x-for="tab in tabs" :key="tab.id">
|
||||
<button @click="activeTab = tab.id"
|
||||
:class="activeTab === tab.id ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap"
|
||||
x-text="tab.label"></button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Overview -->
|
||||
<div x-show="activeTab === 'overview'" class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Contact Info -->
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Contact Info</h3>
|
||||
<template x-for="c in prospect.contacts || []" :key="c.id">
|
||||
<div class="flex items-center justify-between py-2 border-b dark:border-gray-700 last:border-0">
|
||||
<span class="text-xs text-gray-500 uppercase" x-text="c.contact_type"></span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="c.value"></span>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!prospect.contacts?.length" class="text-sm text-gray-400">No contacts found</p>
|
||||
</div>
|
||||
|
||||
<!-- Score Breakdown -->
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Score Breakdown</h3>
|
||||
<template x-if="prospect.score">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Technical Health</span>
|
||||
<span class="font-semibold" x-text="prospect.score.technical_health_score + '/40'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Modernity</span>
|
||||
<span class="font-semibold" x-text="prospect.score.modernity_score + '/25'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Business Value</span>
|
||||
<span class="font-semibold" x-text="prospect.score.business_value_score + '/25'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Engagement</span>
|
||||
<span class="font-semibold" x-text="prospect.score.engagement_score + '/10'"></span>
|
||||
</div>
|
||||
<div class="pt-3 border-t dark:border-gray-700">
|
||||
<h4 class="text-xs font-semibold text-gray-500 uppercase mb-2">Issues</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="flag in prospect.score.reason_flags || []" :key="flag">
|
||||
<span class="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full dark:bg-red-900 dark:text-red-300"
|
||||
x-text="flag.replace('_', ' ')"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!prospect.score" class="text-sm text-gray-400">Not scored yet</p>
|
||||
</div>
|
||||
|
||||
<!-- Tech Profile Summary -->
|
||||
<div x-show="prospect.tech_profile" class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Technology</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<template x-for="[key, val] in techProfileEntries()" :key="key">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400 capitalize" x-text="key.replace('_', ' ')"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="val"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Summary -->
|
||||
<div x-show="prospect.performance_profile" class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Performance</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Performance Score</span>
|
||||
<span class="font-semibold" :class="scoreColor(prospect.performance_profile?.performance_score)"
|
||||
x-text="prospect.performance_profile?.performance_score ?? '—'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Mobile Friendly</span>
|
||||
<span x-text="prospect.performance_profile?.is_mobile_friendly ? 'Yes' : 'No'"
|
||||
:class="prospect.performance_profile?.is_mobile_friendly ? 'text-green-600' : 'text-red-600'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">SEO Score</span>
|
||||
<span x-text="prospect.performance_profile?.seo_score ?? '—'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Interactions -->
|
||||
<div x-show="activeTab === 'interactions'" class="space-y-4">
|
||||
<div class="flex justify-end">
|
||||
<button @click="showInteractionModal = true"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
<span x-html="$icon('plus', 'w-4 h-4 inline mr-1')"></span>
|
||||
Log Interaction
|
||||
</button>
|
||||
</div>
|
||||
<!-- Timeline -->
|
||||
<div class="space-y-4">
|
||||
<template x-for="interaction in interactions" :key="interaction.id">
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800 border-l-4"
|
||||
:class="interaction.outcome === 'positive' ? 'border-green-500' : interaction.outcome === 'negative' ? 'border-red-500' : 'border-gray-300'">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
|
||||
x-text="interaction.interaction_type.replace('_', ' ')"></span>
|
||||
<span class="text-xs text-gray-500" x-text="new Date(interaction.created_at).toLocaleDateString()"></span>
|
||||
</div>
|
||||
<p x-show="interaction.subject" class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="interaction.subject"></p>
|
||||
<p x-show="interaction.notes" class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="interaction.notes"></p>
|
||||
<p x-show="interaction.next_action" class="text-xs text-purple-600 mt-2">
|
||||
Next: <span x-text="interaction.next_action"></span>
|
||||
<span x-show="interaction.next_action_date" x-text="' — ' + interaction.next_action_date"></span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="interactions.length === 0" class="text-sm text-gray-400 text-center py-8">No interactions yet</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Campaigns -->
|
||||
<div x-show="activeTab === 'campaigns'" class="space-y-4">
|
||||
<div class="flex justify-end">
|
||||
<button @click="showSendCampaignModal = true"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
|
||||
<span x-html="$icon('mail', 'w-4 h-4 inline mr-1')"></span>
|
||||
Send Campaign
|
||||
</button>
|
||||
</div>
|
||||
<template x-for="send in campaignSends" :key="send.id">
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="send.rendered_subject || 'No subject'"></span>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="send.status === 'sent' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'"
|
||||
x-text="send.status"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1" x-text="send.sent_at ? new Date(send.sent_at).toLocaleString() : 'Draft'"></p>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="campaignSends.length === 0" class="text-sm text-gray-400 text-center py-8">No campaigns sent yet</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interaction Modal -->
|
||||
<div x-show="showInteractionModal" x-cloak
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
@click.self="showInteractionModal = false">
|
||||
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
|
||||
@keydown.escape.window="showInteractionModal = false">
|
||||
<header class="flex justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Log Interaction</h3>
|
||||
<button @click="showInteractionModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Type</label>
|
||||
<select x-model="newInteraction.interaction_type"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="note">Note</option>
|
||||
<option value="call">Phone Call</option>
|
||||
<option value="email_sent">Email Sent</option>
|
||||
<option value="email_received">Email Received</option>
|
||||
<option value="meeting">Meeting</option>
|
||||
<option value="visit">Visit</option>
|
||||
<option value="proposal_sent">Proposal Sent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Subject</label>
|
||||
<input type="text" x-model="newInteraction.subject"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Notes</label>
|
||||
<textarea x-model="newInteraction.notes" rows="3"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Outcome</label>
|
||||
<select x-model="newInteraction.outcome"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">Not specified</option>
|
||||
<option value="positive">Positive</option>
|
||||
<option value="neutral">Neutral</option>
|
||||
<option value="negative">Negative</option>
|
||||
<option value="no_answer">No Answer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Next Action</label>
|
||||
<input type="text" x-model="newInteraction.next_action" placeholder="Follow up by..."
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
<footer class="flex justify-end mt-6 space-x-3">
|
||||
<button @click="showInteractionModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="createInteraction()"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
Save
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('prospecting_static', path='admin/js/prospect-detail.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,226 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/tables.html' import table_header, table_empty %}
|
||||
{% from 'shared/macros/pagination.html' import pagination_controls %}
|
||||
|
||||
{% block title %}Prospects{% endblock %}
|
||||
|
||||
{% block alpine_data %}prospectsList(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Prospects', action_label='New Prospect', action_onclick='showCreateModal = true', action_icon='plus') }}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Search</label>
|
||||
<input type="text" x-model="search" @input.debounce.300ms="loadProspects()"
|
||||
placeholder="Domain or business name..."
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:ring-purple-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Channel</label>
|
||||
<select x-model="filterChannel" @change="loadProspects()"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">All</option>
|
||||
<option value="digital">Digital</option>
|
||||
<option value="offline">Offline</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Status</label>
|
||||
<select x-model="filterStatus" @change="loadProspects()"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">All</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="contacted">Contacted</option>
|
||||
<option value="converted">Converted</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Tier</label>
|
||||
<select x-model="filterTier" @change="loadProspects()"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">All</option>
|
||||
<option value="top_priority">Top Priority</option>
|
||||
<option value="quick_win">Quick Win</option>
|
||||
<option value="strategic">Strategic</option>
|
||||
<option value="low_priority">Low Priority</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ loading_state('Loading prospects...') }}
|
||||
{{ error_state('Error loading prospects') }}
|
||||
|
||||
<!-- Prospects Table -->
|
||||
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-nowrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Business / Domain</th>
|
||||
<th class="px-4 py-3">Channel</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Score</th>
|
||||
<th class="px-4 py-3">Tier</th>
|
||||
<th class="px-4 py-3">Contact</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="p in prospects" :key="p.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div>
|
||||
<p class="font-semibold" x-text="p.business_name || p.domain_name"></p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400" x-show="p.domain_name && p.business_name" x-text="p.domain_name"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="p.channel === 'digital' ? 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700' : 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700'"
|
||||
x-text="p.channel"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="statusBadgeClass(p.status)"
|
||||
x-text="p.status"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="p.score?.score ?? '—'" class="font-semibold"
|
||||
:class="scoreColor(p.score?.score)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-show="p.score?.lead_tier"
|
||||
class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="tierBadgeClass(p.score?.lead_tier)"
|
||||
x-text="p.score?.lead_tier?.replace('_', ' ')"></span>
|
||||
<span x-show="!p.score?.lead_tier" class="text-gray-400">—</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<template x-if="p.primary_email">
|
||||
<span class="text-xs" x-text="p.primary_email"></span>
|
||||
</template>
|
||||
<span x-show="!p.primary_email" class="text-gray-400">—</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<a :href="'/admin/prospecting/prospects/' + p.id"
|
||||
class="text-purple-600 hover:text-purple-900 dark:text-purple-400 dark:hover:text-purple-300">
|
||||
View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{ table_empty('No prospects found') }}
|
||||
</div>
|
||||
|
||||
{{ pagination_controls() }}
|
||||
|
||||
<!-- Create Prospect Modal -->
|
||||
<div x-show="showCreateModal" x-cloak
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
@click.self="showCreateModal = false">
|
||||
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
|
||||
@keydown.escape.window="showCreateModal = false">
|
||||
<header class="flex justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">New Prospect</h3>
|
||||
<button @click="showCreateModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Channel Toggle -->
|
||||
<div class="flex mb-4 space-x-2">
|
||||
<button @click="newProspect.channel = 'digital'"
|
||||
:class="newProspect.channel === 'digital' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium rounded-lg">
|
||||
Digital (Domain)
|
||||
</button>
|
||||
<button @click="newProspect.channel = 'offline'"
|
||||
:class="newProspect.channel === 'offline' ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium rounded-lg">
|
||||
Offline (Manual)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Digital Fields -->
|
||||
<div x-show="newProspect.channel === 'digital'" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Domain Name</label>
|
||||
<input type="text" x-model="newProspect.domain_name" placeholder="example.lu"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Offline Fields -->
|
||||
<div x-show="newProspect.channel === 'offline'" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Business Name *</label>
|
||||
<input type="text" x-model="newProspect.business_name" placeholder="Business name"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Phone</label>
|
||||
<input type="tel" x-model="newProspect.phone" placeholder="+352..."
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Email</label>
|
||||
<input type="email" x-model="newProspect.email" placeholder="contact@..."
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">City</label>
|
||||
<input type="text" x-model="newProspect.city" placeholder="Luxembourg"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Source</label>
|
||||
<select x-model="newProspect.source"
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="street">Street</option>
|
||||
<option value="networking_event">Networking Event</option>
|
||||
<option value="referral">Referral</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Notes</label>
|
||||
<textarea x-model="newProspect.notes" rows="3" placeholder="Any notes about this business..."
|
||||
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="flex justify-end mt-6 space-x-3">
|
||||
<button @click="showCreateModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="createProspect()"
|
||||
:disabled="creating"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
<span x-show="!creating">Create</span>
|
||||
<span x-show="creating">Creating...</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('prospecting_static', path='admin/js/prospects.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,94 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/pagination.html' import pagination_controls %}
|
||||
|
||||
{% block title %}Scan Jobs{% endblock %}
|
||||
|
||||
{% block alpine_data %}scanJobs(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Scan Jobs') }}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mb-6 flex flex-wrap gap-3">
|
||||
<button @click="startBatchJob('http_check')"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">
|
||||
<span x-html="$icon('globe', 'w-4 h-4 mr-2')"></span>
|
||||
HTTP Check
|
||||
</button>
|
||||
<button @click="startBatchJob('tech_scan')"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
|
||||
<span x-html="$icon('code', 'w-4 h-4 mr-2')"></span>
|
||||
Tech Scan
|
||||
</button>
|
||||
<button @click="startBatchJob('performance_scan')"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700">
|
||||
<span x-html="$icon('chart-bar', 'w-4 h-4 mr-2')"></span>
|
||||
Performance Scan
|
||||
</button>
|
||||
<button @click="startBatchJob('contact_scrape')"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
|
||||
<span x-html="$icon('mail', 'w-4 h-4 mr-2')"></span>
|
||||
Contact Scrape
|
||||
</button>
|
||||
<button @click="startBatchJob('score_compute')"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
|
||||
<span x-html="$icon('target', 'w-4 h-4 mr-2')"></span>
|
||||
Compute Scores
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{ loading_state('Loading scan jobs...') }}
|
||||
{{ error_state('Error loading scan jobs') }}
|
||||
|
||||
<!-- Jobs Table -->
|
||||
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-nowrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Job Type</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Progress</th>
|
||||
<th class="px-4 py-3">Started</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="job in jobs" :key="job.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3 text-sm font-semibold" x-text="job.job_type.replace('_', ' ')"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="jobStatusClass(job.status)"
|
||||
x-text="job.status"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-24 h-2 bg-gray-200 rounded-full dark:bg-gray-700">
|
||||
<div class="h-2 bg-blue-500 rounded-full" :style="'width: ' + (job.progress_percent || 0) + '%'"></div>
|
||||
</div>
|
||||
<span class="text-xs" x-text="job.processed_items + '/' + job.total_items"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs" x-text="job.started_at ? new Date(job.started_at).toLocaleString() : '—'"></td>
|
||||
<td class="px-4 py-3 text-xs" x-text="formatDuration(job)"></td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span x-show="job.failed_items > 0" class="text-red-600" x-text="job.failed_items + ' failed'"></span>
|
||||
<span x-show="job.skipped_items > 0" class="text-yellow-600 ml-1" x-text="job.skipped_items + ' skipped'"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ pagination_controls() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('prospecting_static', path='admin/js/scan-jobs.js') }}"></script>
|
||||
{% endblock %}
|
||||
1
app/modules/prospecting/tests/__init__.py
Normal file
1
app/modules/prospecting/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/prospecting/tests/__init__.py
|
||||
231
app/modules/prospecting/tests/conftest.py
Normal file
231
app/modules/prospecting/tests/conftest.py
Normal file
@@ -0,0 +1,231 @@
|
||||
# app/modules/prospecting/tests/conftest.py
|
||||
"""
|
||||
Module-specific fixtures for prospecting tests.
|
||||
Core fixtures (db, client, etc.) are inherited from the root conftest.py.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.models import (
|
||||
CampaignTemplate,
|
||||
Prospect,
|
||||
ProspectContact,
|
||||
ProspectInteraction,
|
||||
ProspectPerformanceProfile,
|
||||
ProspectScore,
|
||||
ProspectTechProfile,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def digital_prospect(db):
|
||||
"""Create a digital prospect with a domain."""
|
||||
prospect = Prospect(
|
||||
channel="digital",
|
||||
domain_name=f"test-{uuid.uuid4().hex[:8]}.lu",
|
||||
status="active",
|
||||
source="domain_scan",
|
||||
has_website=True,
|
||||
uses_https=True,
|
||||
http_status_code=200,
|
||||
country="LU",
|
||||
)
|
||||
db.add(prospect)
|
||||
db.commit()
|
||||
db.refresh(prospect)
|
||||
return prospect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def offline_prospect(db):
|
||||
"""Create an offline prospect with business details."""
|
||||
prospect = Prospect(
|
||||
channel="offline",
|
||||
business_name=f"Test Business {uuid.uuid4().hex[:8]}",
|
||||
status="pending",
|
||||
source="networking_event",
|
||||
city="Luxembourg",
|
||||
postal_code="1234",
|
||||
country="LU",
|
||||
notes="Met at networking event",
|
||||
tags=json.dumps(["networking", "no-website"]),
|
||||
)
|
||||
db.add(prospect)
|
||||
db.commit()
|
||||
db.refresh(prospect)
|
||||
return prospect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prospect_with_tech(db, digital_prospect):
|
||||
"""Create a digital prospect with tech profile."""
|
||||
tech = ProspectTechProfile(
|
||||
prospect_id=digital_prospect.id,
|
||||
cms="WordPress",
|
||||
cms_version="5.9",
|
||||
server="nginx",
|
||||
js_framework="jQuery",
|
||||
analytics="Google Analytics",
|
||||
ecommerce_platform=None,
|
||||
)
|
||||
db.add(tech)
|
||||
db.commit()
|
||||
db.refresh(digital_prospect)
|
||||
return digital_prospect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prospect_with_performance(db, digital_prospect):
|
||||
"""Create a digital prospect with performance profile."""
|
||||
perf = ProspectPerformanceProfile(
|
||||
prospect_id=digital_prospect.id,
|
||||
performance_score=45,
|
||||
accessibility_score=70,
|
||||
seo_score=60,
|
||||
first_contentful_paint_ms=2500,
|
||||
largest_contentful_paint_ms=4200,
|
||||
total_blocking_time_ms=350,
|
||||
cumulative_layout_shift=0.15,
|
||||
is_mobile_friendly=False,
|
||||
)
|
||||
db.add(perf)
|
||||
db.commit()
|
||||
db.refresh(digital_prospect)
|
||||
return digital_prospect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prospect_with_contacts(db, digital_prospect):
|
||||
"""Create a digital prospect with contacts."""
|
||||
contacts = [
|
||||
ProspectContact(
|
||||
prospect_id=digital_prospect.id,
|
||||
contact_type="email",
|
||||
value="info@test.lu",
|
||||
source_url="https://test.lu/contact",
|
||||
is_primary=True,
|
||||
),
|
||||
ProspectContact(
|
||||
prospect_id=digital_prospect.id,
|
||||
contact_type="phone",
|
||||
value="+352 123 456",
|
||||
source_url="https://test.lu/contact",
|
||||
is_primary=True,
|
||||
),
|
||||
]
|
||||
db.add_all(contacts)
|
||||
db.commit()
|
||||
db.refresh(digital_prospect)
|
||||
return digital_prospect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prospect_with_score(db, digital_prospect):
|
||||
"""Create a digital prospect with a score."""
|
||||
score = ProspectScore(
|
||||
prospect_id=digital_prospect.id,
|
||||
score=72,
|
||||
technical_health_score=25,
|
||||
modernity_score=20,
|
||||
business_value_score=20,
|
||||
engagement_score=7,
|
||||
reason_flags=json.dumps(["no_ssl", "slow"]),
|
||||
score_breakdown=json.dumps({"no_ssl": 15, "slow": 10}),
|
||||
lead_tier="top_priority",
|
||||
)
|
||||
db.add(score)
|
||||
db.commit()
|
||||
db.refresh(digital_prospect)
|
||||
return digital_prospect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prospect_full(db):
|
||||
"""Create a fully enriched digital prospect with all related data."""
|
||||
prospect = Prospect(
|
||||
channel="digital",
|
||||
domain_name=f"full-{uuid.uuid4().hex[:8]}.lu",
|
||||
status="active",
|
||||
source="domain_scan",
|
||||
has_website=True,
|
||||
uses_https=False,
|
||||
http_status_code=200,
|
||||
country="LU",
|
||||
)
|
||||
db.add(prospect)
|
||||
db.flush()
|
||||
|
||||
tech = ProspectTechProfile(
|
||||
prospect_id=prospect.id,
|
||||
cms="Drupal",
|
||||
cms_version="7.0",
|
||||
server="Apache",
|
||||
js_framework="jQuery",
|
||||
)
|
||||
perf = ProspectPerformanceProfile(
|
||||
prospect_id=prospect.id,
|
||||
performance_score=25,
|
||||
accessibility_score=50,
|
||||
seo_score=40,
|
||||
is_mobile_friendly=False,
|
||||
)
|
||||
contact = ProspectContact(
|
||||
prospect_id=prospect.id,
|
||||
contact_type="email",
|
||||
value="info@full-test.lu",
|
||||
is_primary=True,
|
||||
)
|
||||
score = ProspectScore(
|
||||
prospect_id=prospect.id,
|
||||
score=85,
|
||||
technical_health_score=35,
|
||||
modernity_score=20,
|
||||
business_value_score=20,
|
||||
engagement_score=10,
|
||||
reason_flags=json.dumps(["no_ssl", "very_slow", "outdated_cms", "not_mobile_friendly"]),
|
||||
lead_tier="top_priority",
|
||||
)
|
||||
|
||||
db.add_all([tech, perf, contact, score])
|
||||
db.commit()
|
||||
db.refresh(prospect)
|
||||
return prospect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def campaign_template(db):
|
||||
"""Create a campaign template."""
|
||||
template = CampaignTemplate(
|
||||
name="Test Template",
|
||||
lead_type="bad_website",
|
||||
channel="email",
|
||||
language="fr",
|
||||
subject_template="Votre site {domain} a des problèmes",
|
||||
body_template="Bonjour {business_name},\n\nVotre site {domain} a un score de {score}.\n\nIssues: {issues}",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(template)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
return template
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def interaction(db, digital_prospect):
|
||||
"""Create a prospect interaction."""
|
||||
interaction = ProspectInteraction(
|
||||
prospect_id=digital_prospect.id,
|
||||
interaction_type="call",
|
||||
subject="Initial contact",
|
||||
notes="Discussed website needs",
|
||||
outcome="positive",
|
||||
next_action="Send proposal",
|
||||
created_by_user_id=1,
|
||||
)
|
||||
db.add(interaction)
|
||||
db.commit()
|
||||
db.refresh(interaction)
|
||||
return interaction
|
||||
1
app/modules/prospecting/tests/unit/__init__.py
Normal file
1
app/modules/prospecting/tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/prospecting/tests/unit/__init__.py
|
||||
107
app/modules/prospecting/tests/unit/test_campaign_service.py
Normal file
107
app/modules/prospecting/tests/unit/test_campaign_service.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# app/modules/prospecting/tests/unit/test_campaign_service.py
|
||||
"""
|
||||
Unit tests for CampaignService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.exceptions import CampaignTemplateNotFoundException
|
||||
from app.modules.prospecting.services.campaign_service import CampaignService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.prospecting
|
||||
class TestCampaignService:
|
||||
"""Tests for CampaignService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = CampaignService()
|
||||
|
||||
def test_create_template(self, db):
|
||||
"""Test creating a campaign template."""
|
||||
template = self.service.create_template(db, {
|
||||
"name": "Test Campaign",
|
||||
"lead_type": "bad_website",
|
||||
"channel": "email",
|
||||
"language": "fr",
|
||||
"subject_template": "Subject for {domain}",
|
||||
"body_template": "Hello {business_name}, your site {domain} has issues.",
|
||||
})
|
||||
|
||||
assert template.id is not None
|
||||
assert template.name == "Test Campaign"
|
||||
assert template.lead_type == "bad_website"
|
||||
|
||||
def test_get_templates(self, db, campaign_template):
|
||||
"""Test listing campaign templates."""
|
||||
templates = self.service.get_templates(db)
|
||||
assert len(templates) >= 1
|
||||
|
||||
def test_get_templates_filter_lead_type(self, db, campaign_template):
|
||||
"""Test filtering templates by lead type."""
|
||||
templates = self.service.get_templates(db, lead_type="bad_website")
|
||||
assert len(templates) >= 1
|
||||
|
||||
templates = self.service.get_templates(db, lead_type="no_website")
|
||||
# campaign_template is bad_website, so this might be empty
|
||||
assert isinstance(templates, list)
|
||||
|
||||
def test_get_template_by_id(self, db, campaign_template):
|
||||
"""Test getting a template by ID."""
|
||||
result = self.service.get_template_by_id(db, campaign_template.id)
|
||||
assert result.id == campaign_template.id
|
||||
|
||||
def test_get_template_not_found(self, db):
|
||||
"""Test getting non-existent template raises exception."""
|
||||
with pytest.raises(CampaignTemplateNotFoundException):
|
||||
self.service.get_template_by_id(db, 99999)
|
||||
|
||||
def test_update_template(self, db, campaign_template):
|
||||
"""Test updating a template."""
|
||||
updated = self.service.update_template(
|
||||
db, campaign_template.id, {"name": "Updated Campaign"}
|
||||
)
|
||||
assert updated.name == "Updated Campaign"
|
||||
|
||||
def test_delete_template(self, db, campaign_template):
|
||||
"""Test deleting a template."""
|
||||
tid = campaign_template.id
|
||||
self.service.delete_template(db, tid)
|
||||
|
||||
with pytest.raises(CampaignTemplateNotFoundException):
|
||||
self.service.get_template_by_id(db, tid)
|
||||
|
||||
def test_render_campaign(self, db, campaign_template, prospect_with_score):
|
||||
"""Test rendering a campaign with prospect data."""
|
||||
result = self.service.render_campaign(
|
||||
db, campaign_template.id, prospect_with_score.id
|
||||
)
|
||||
|
||||
assert "subject" in result
|
||||
assert "body" in result
|
||||
assert prospect_with_score.domain_name in result["subject"]
|
||||
|
||||
def test_send_campaign(self, db, campaign_template, digital_prospect):
|
||||
"""Test sending a campaign to prospects."""
|
||||
sends = self.service.send_campaign(
|
||||
db,
|
||||
template_id=campaign_template.id,
|
||||
prospect_ids=[digital_prospect.id],
|
||||
sent_by_user_id=1,
|
||||
)
|
||||
|
||||
assert len(sends) == 1
|
||||
assert sends[0].prospect_id == digital_prospect.id
|
||||
assert sends[0].status.value == "sent"
|
||||
|
||||
def test_get_sends(self, db, campaign_template, digital_prospect):
|
||||
"""Test getting campaign sends."""
|
||||
self.service.send_campaign(
|
||||
db,
|
||||
template_id=campaign_template.id,
|
||||
prospect_ids=[digital_prospect.id],
|
||||
sent_by_user_id=1,
|
||||
)
|
||||
|
||||
sends = self.service.get_sends(db, prospect_id=digital_prospect.id)
|
||||
assert len(sends) >= 1
|
||||
@@ -0,0 +1,98 @@
|
||||
# app/modules/prospecting/tests/unit/test_enrichment_service.py
|
||||
"""
|
||||
Unit tests for EnrichmentService.
|
||||
|
||||
Note: These tests mock external HTTP calls to avoid real network requests.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.services.enrichment_service import EnrichmentService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.prospecting
|
||||
class TestEnrichmentService:
|
||||
"""Tests for EnrichmentService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = EnrichmentService()
|
||||
|
||||
def test_check_http_success(self, db, digital_prospect):
|
||||
"""Test HTTP check with mocked successful response."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.url = f"https://{digital_prospect.domain_name}"
|
||||
mock_response.headers = {}
|
||||
|
||||
with patch("app.modules.prospecting.services.enrichment_service.requests.get", return_value=mock_response):
|
||||
result = self.service.check_http(db, digital_prospect)
|
||||
|
||||
assert result["has_website"] is True
|
||||
assert result["uses_https"] is True
|
||||
|
||||
def test_check_http_no_website(self, db, digital_prospect):
|
||||
"""Test HTTP check when website doesn't respond."""
|
||||
import requests as req
|
||||
|
||||
with patch(
|
||||
"app.modules.prospecting.services.enrichment_service.requests.get",
|
||||
side_effect=req.exceptions.ConnectionError("Connection refused"),
|
||||
):
|
||||
result = self.service.check_http(db, digital_prospect)
|
||||
|
||||
assert result["has_website"] is False
|
||||
|
||||
def test_check_http_no_domain(self, db, offline_prospect):
|
||||
"""Test HTTP check with no domain name."""
|
||||
result = self.service.check_http(db, offline_prospect)
|
||||
assert result["has_website"] is False
|
||||
assert result["error"] == "No domain name"
|
||||
|
||||
def test_detect_cms_wordpress(self):
|
||||
"""Test CMS detection for WordPress."""
|
||||
html = '<link rel="stylesheet" href="/wp-content/themes/test/style.css">'
|
||||
assert self.service._detect_cms(html) == "wordpress"
|
||||
|
||||
def test_detect_cms_drupal(self):
|
||||
"""Test CMS detection for Drupal."""
|
||||
html = '<meta name="generator" content="drupal 7">'
|
||||
assert self.service._detect_cms(html) == "drupal"
|
||||
|
||||
def test_detect_cms_none(self):
|
||||
"""Test CMS detection when no CMS detected."""
|
||||
html = "<html><body>Hello world</body></html>"
|
||||
assert self.service._detect_cms(html) is None
|
||||
|
||||
def test_detect_js_framework_jquery(self):
|
||||
"""Test JS framework detection for jQuery."""
|
||||
html = '<script src="/js/jquery.min.js"></script>'
|
||||
assert self.service._detect_js_framework(html) == "jquery"
|
||||
|
||||
def test_detect_js_framework_react(self):
|
||||
"""Test JS framework detection for React."""
|
||||
html = '<script id="__NEXT_DATA__" type="application/json">{}</script>'
|
||||
assert self.service._detect_js_framework(html) == "react"
|
||||
|
||||
def test_detect_analytics_google(self):
|
||||
"""Test analytics detection for Google Analytics."""
|
||||
html = '<script async src="https://www.googletagmanager.com/gtag/js?id=G-123"></script>'
|
||||
result = self.service._detect_analytics(html)
|
||||
assert result is not None
|
||||
assert "google" in result
|
||||
|
||||
def test_detect_analytics_none(self):
|
||||
"""Test analytics detection when no analytics detected."""
|
||||
html = "<html><body>No analytics here</body></html>"
|
||||
assert self.service._detect_analytics(html) is None
|
||||
|
||||
def test_service_instance_exists(self):
|
||||
"""Test that service singleton is importable."""
|
||||
from app.modules.prospecting.services.enrichment_service import (
|
||||
enrichment_service,
|
||||
)
|
||||
|
||||
assert enrichment_service is not None
|
||||
assert isinstance(enrichment_service, EnrichmentService)
|
||||
@@ -0,0 +1,77 @@
|
||||
# app/modules/prospecting/tests/unit/test_interaction_service.py
|
||||
"""
|
||||
Unit tests for InteractionService.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.services.interaction_service import InteractionService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.prospecting
|
||||
class TestInteractionService:
|
||||
"""Tests for InteractionService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = InteractionService()
|
||||
|
||||
def test_create_interaction(self, db, digital_prospect):
|
||||
"""Test creating an interaction."""
|
||||
interaction = self.service.create(
|
||||
db,
|
||||
prospect_id=digital_prospect.id,
|
||||
user_id=1,
|
||||
data={
|
||||
"interaction_type": "call",
|
||||
"subject": "Initial call",
|
||||
"notes": "Discussed website needs",
|
||||
"outcome": "positive",
|
||||
},
|
||||
)
|
||||
|
||||
assert interaction.id is not None
|
||||
assert interaction.interaction_type.value == "call"
|
||||
assert interaction.outcome.value == "positive"
|
||||
|
||||
def test_create_interaction_with_follow_up(self, db, digital_prospect):
|
||||
"""Test creating an interaction with follow-up action."""
|
||||
follow_up_date = date.today() + timedelta(days=7)
|
||||
interaction = self.service.create(
|
||||
db,
|
||||
prospect_id=digital_prospect.id,
|
||||
user_id=1,
|
||||
data={
|
||||
"interaction_type": "meeting",
|
||||
"subject": "Office visit",
|
||||
"next_action": "Send proposal",
|
||||
"next_action_date": str(follow_up_date),
|
||||
},
|
||||
)
|
||||
|
||||
assert interaction.next_action == "Send proposal"
|
||||
|
||||
def test_get_for_prospect(self, db, digital_prospect, interaction):
|
||||
"""Test getting interactions for a prospect."""
|
||||
interactions = self.service.get_for_prospect(db, digital_prospect.id)
|
||||
assert len(interactions) >= 1
|
||||
assert interactions[0].prospect_id == digital_prospect.id
|
||||
|
||||
def test_get_upcoming_actions(self, db, digital_prospect):
|
||||
"""Test getting upcoming follow-up actions."""
|
||||
follow_up_date = date.today() + timedelta(days=3)
|
||||
self.service.create(
|
||||
db,
|
||||
prospect_id=digital_prospect.id,
|
||||
user_id=1,
|
||||
data={
|
||||
"interaction_type": "call",
|
||||
"next_action": "Follow up",
|
||||
"next_action_date": str(follow_up_date),
|
||||
},
|
||||
)
|
||||
|
||||
upcoming = self.service.get_upcoming_actions(db)
|
||||
assert len(upcoming) >= 1
|
||||
59
app/modules/prospecting/tests/unit/test_lead_service.py
Normal file
59
app/modules/prospecting/tests/unit/test_lead_service.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# app/modules/prospecting/tests/unit/test_lead_service.py
|
||||
"""
|
||||
Unit tests for LeadService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.services.lead_service import LeadService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.prospecting
|
||||
class TestLeadService:
|
||||
"""Tests for LeadService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = LeadService()
|
||||
|
||||
def test_get_leads_empty(self, db):
|
||||
"""Test getting leads when none exist."""
|
||||
leads, total = self.service.get_leads(db)
|
||||
assert total == 0
|
||||
assert leads == []
|
||||
|
||||
def test_get_leads_with_scored_prospect(self, db, prospect_with_score):
|
||||
"""Test getting leads returns scored prospects."""
|
||||
leads, total = self.service.get_leads(db)
|
||||
assert total >= 1
|
||||
|
||||
def test_get_leads_filter_min_score(self, db, prospect_with_score):
|
||||
"""Test filtering leads by minimum score."""
|
||||
leads, total = self.service.get_leads(db, min_score=70)
|
||||
assert total >= 1 # prospect_with_score has score 72
|
||||
|
||||
leads, total = self.service.get_leads(db, min_score=90)
|
||||
assert total == 0 # No prospect has score >= 90
|
||||
|
||||
def test_get_leads_filter_lead_tier(self, db, prospect_with_score):
|
||||
"""Test filtering leads by tier."""
|
||||
leads, total = self.service.get_leads(db, lead_tier="top_priority")
|
||||
assert total >= 1 # prospect_with_score is top_priority (72)
|
||||
|
||||
def test_get_top_priority(self, db, prospect_with_score):
|
||||
"""Test getting top priority leads."""
|
||||
leads = self.service.get_top_priority(db)
|
||||
assert len(leads) >= 1
|
||||
|
||||
def test_get_quick_wins(self, db, prospect_with_score):
|
||||
"""Test getting quick win leads (score 50-69)."""
|
||||
# prospect_with_score has score 72, not a quick win
|
||||
leads = self.service.get_quick_wins(db)
|
||||
# May be empty if no prospects in 50-69 range
|
||||
assert isinstance(leads, list)
|
||||
|
||||
def test_export_csv(self, db, prospect_with_score):
|
||||
"""Test CSV export returns valid CSV content."""
|
||||
csv_content = self.service.export_csv(db)
|
||||
assert isinstance(csv_content, str)
|
||||
assert "domain" in csv_content.lower() or "score" in csv_content.lower()
|
||||
149
app/modules/prospecting/tests/unit/test_prospect_service.py
Normal file
149
app/modules/prospecting/tests/unit/test_prospect_service.py
Normal file
@@ -0,0 +1,149 @@
|
||||
# app/modules/prospecting/tests/unit/test_prospect_service.py
|
||||
"""
|
||||
Unit tests for ProspectService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.exceptions import ProspectNotFoundException
|
||||
from app.modules.prospecting.services.prospect_service import ProspectService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.prospecting
|
||||
class TestProspectService:
|
||||
"""Tests for ProspectService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = ProspectService()
|
||||
|
||||
def test_create_digital_prospect(self, db):
|
||||
"""Test creating a digital prospect."""
|
||||
prospect = self.service.create(
|
||||
db,
|
||||
{"channel": "digital", "domain_name": "example.lu", "source": "domain_scan"},
|
||||
)
|
||||
|
||||
assert prospect.id is not None
|
||||
assert prospect.channel.value == "digital"
|
||||
assert prospect.domain_name == "example.lu"
|
||||
assert prospect.status.value == "pending"
|
||||
|
||||
def test_create_offline_prospect(self, db):
|
||||
"""Test creating an offline prospect."""
|
||||
prospect = self.service.create(
|
||||
db,
|
||||
{
|
||||
"channel": "offline",
|
||||
"business_name": "Test Café",
|
||||
"source": "street",
|
||||
"city": "Luxembourg",
|
||||
},
|
||||
)
|
||||
|
||||
assert prospect.id is not None
|
||||
assert prospect.channel.value == "offline"
|
||||
assert prospect.business_name == "Test Café"
|
||||
assert prospect.city == "Luxembourg"
|
||||
|
||||
def test_create_digital_prospect_with_contacts(self, db):
|
||||
"""Test creating a digital prospect with inline contacts."""
|
||||
prospect = self.service.create(
|
||||
db,
|
||||
{
|
||||
"channel": "digital",
|
||||
"domain_name": "with-contacts.lu",
|
||||
"contacts": [
|
||||
{"contact_type": "email", "value": "info@with-contacts.lu"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert prospect.id is not None
|
||||
assert len(prospect.contacts) == 1
|
||||
assert prospect.contacts[0].value == "info@with-contacts.lu"
|
||||
|
||||
def test_get_by_id(self, db, digital_prospect):
|
||||
"""Test getting a prospect by ID."""
|
||||
result = self.service.get_by_id(db, digital_prospect.id)
|
||||
assert result.id == digital_prospect.id
|
||||
assert result.domain_name == digital_prospect.domain_name
|
||||
|
||||
def test_get_by_id_not_found(self, db):
|
||||
"""Test getting non-existent prospect raises exception."""
|
||||
with pytest.raises(ProspectNotFoundException):
|
||||
self.service.get_by_id(db, 99999)
|
||||
|
||||
def test_get_by_domain(self, db, digital_prospect):
|
||||
"""Test getting a prospect by domain name."""
|
||||
result = self.service.get_by_domain(db, digital_prospect.domain_name)
|
||||
assert result is not None
|
||||
assert result.id == digital_prospect.id
|
||||
|
||||
def test_get_all_with_pagination(self, db, digital_prospect, offline_prospect):
|
||||
"""Test listing prospects with pagination."""
|
||||
prospects, total = self.service.get_all(db, page=1, per_page=10)
|
||||
assert total >= 2
|
||||
assert len(prospects) >= 2
|
||||
|
||||
def test_get_all_filter_by_channel(self, db, digital_prospect, offline_prospect):
|
||||
"""Test filtering prospects by channel."""
|
||||
digital, count = self.service.get_all(db, channel="digital")
|
||||
assert count >= 1
|
||||
assert all(p.channel.value == "digital" for p in digital)
|
||||
|
||||
def test_get_all_filter_by_status(self, db, digital_prospect):
|
||||
"""Test filtering prospects by status."""
|
||||
active, count = self.service.get_all(db, status="active")
|
||||
assert count >= 1
|
||||
assert all(p.status.value == "active" for p in active)
|
||||
|
||||
def test_get_all_search(self, db, digital_prospect):
|
||||
"""Test searching prospects by domain or business name."""
|
||||
domain = digital_prospect.domain_name
|
||||
results, count = self.service.get_all(db, search=domain[:8])
|
||||
assert count >= 1
|
||||
|
||||
def test_update_prospect(self, db, digital_prospect):
|
||||
"""Test updating a prospect."""
|
||||
updated = self.service.update(
|
||||
db, digital_prospect.id, {"notes": "Updated notes", "status": "contacted"}
|
||||
)
|
||||
assert updated.notes == "Updated notes"
|
||||
assert updated.status.value == "contacted"
|
||||
|
||||
def test_delete_prospect(self, db, digital_prospect):
|
||||
"""Test deleting a prospect."""
|
||||
pid = digital_prospect.id
|
||||
self.service.delete(db, pid)
|
||||
|
||||
with pytest.raises(ProspectNotFoundException):
|
||||
self.service.get_by_id(db, pid)
|
||||
|
||||
def test_create_bulk(self, db):
|
||||
"""Test bulk creating prospects from domain list."""
|
||||
domains = ["bulk1.lu", "bulk2.lu", "bulk3.lu"]
|
||||
created, skipped = self.service.create_bulk(db, domains, source="csv_import")
|
||||
assert created == 3
|
||||
assert skipped == 0
|
||||
|
||||
def test_create_bulk_skips_duplicates(self, db, digital_prospect):
|
||||
"""Test bulk create skips existing domains."""
|
||||
domains = [digital_prospect.domain_name, "new-domain.lu"]
|
||||
created, skipped = self.service.create_bulk(db, domains, source="csv_import")
|
||||
assert created == 1
|
||||
assert skipped == 1
|
||||
|
||||
def test_count_by_status(self, db, digital_prospect, offline_prospect):
|
||||
"""Test counting prospects by status."""
|
||||
counts = self.service.count_by_status(db)
|
||||
assert isinstance(counts, dict)
|
||||
assert counts.get("active", 0) >= 1
|
||||
assert counts.get("pending", 0) >= 1
|
||||
|
||||
def test_count_by_channel(self, db, digital_prospect, offline_prospect):
|
||||
"""Test counting prospects by channel."""
|
||||
counts = self.service.count_by_channel(db)
|
||||
assert isinstance(counts, dict)
|
||||
assert counts.get("digital", 0) >= 1
|
||||
assert counts.get("offline", 0) >= 1
|
||||
89
app/modules/prospecting/tests/unit/test_scoring_service.py
Normal file
89
app/modules/prospecting/tests/unit/test_scoring_service.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# app/modules/prospecting/tests/unit/test_scoring_service.py
|
||||
"""
|
||||
Unit tests for ScoringService.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.services.scoring_service import ScoringService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.prospecting
|
||||
class TestScoringService:
|
||||
"""Tests for ScoringService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = ScoringService()
|
||||
|
||||
def test_score_digital_no_ssl(self, db, digital_prospect):
|
||||
"""Test scoring a digital prospect without SSL."""
|
||||
digital_prospect.uses_https = False
|
||||
db.commit()
|
||||
db.refresh(digital_prospect)
|
||||
|
||||
score = self.service.compute_score(db, digital_prospect)
|
||||
assert score.score > 0
|
||||
flags = json.loads(score.reason_flags) if score.reason_flags else []
|
||||
assert "no_ssl" in flags
|
||||
|
||||
def test_score_digital_slow_site(self, db, prospect_with_performance):
|
||||
"""Test scoring a prospect with slow performance."""
|
||||
score = self.service.compute_score(db, prospect_with_performance)
|
||||
assert score.technical_health_score > 0
|
||||
flags = json.loads(score.reason_flags) if score.reason_flags else []
|
||||
assert "slow" in flags or "very_slow" in flags
|
||||
|
||||
def test_score_digital_outdated_cms(self, db, prospect_with_tech):
|
||||
"""Test scoring a prospect with outdated CMS (jQuery counts as legacy)."""
|
||||
# WordPress is not "outdated" but jQuery is legacy
|
||||
score = self.service.compute_score(db, prospect_with_tech)
|
||||
flags = json.loads(score.reason_flags) if score.reason_flags else []
|
||||
assert "legacy_js" in flags
|
||||
|
||||
def test_score_digital_full_prospect(self, db, prospect_full):
|
||||
"""Test scoring a fully enriched prospect (no SSL, slow, outdated CMS)."""
|
||||
score = self.service.compute_score(db, prospect_full)
|
||||
assert score.score >= 70
|
||||
assert score.lead_tier == "top_priority"
|
||||
|
||||
def test_score_offline_no_website(self, db, offline_prospect):
|
||||
"""Test scoring an offline prospect with no website (base score 70)."""
|
||||
score = self.service.compute_score(db, offline_prospect)
|
||||
assert score.score >= 70
|
||||
assert score.lead_tier == "top_priority"
|
||||
flags = json.loads(score.reason_flags) if score.reason_flags else []
|
||||
assert "no_website" in flags
|
||||
|
||||
def test_score_components_max(self, db, prospect_full):
|
||||
"""Test that score components don't exceed their maximums."""
|
||||
score = self.service.compute_score(db, prospect_full)
|
||||
assert score.technical_health_score <= 40
|
||||
assert score.modernity_score <= 25
|
||||
assert score.business_value_score <= 25
|
||||
assert score.engagement_score <= 10
|
||||
assert score.score <= 100
|
||||
|
||||
def test_lead_tier_assignment(self, db, digital_prospect, offline_prospect):
|
||||
"""Test that lead tiers are correctly assigned based on score."""
|
||||
# Digital prospect with website + SSL should be low score
|
||||
score1 = self.service.compute_score(db, digital_prospect)
|
||||
assert score1.lead_tier in ("top_priority", "quick_win", "strategic", "low_priority")
|
||||
|
||||
# Offline prospect with no website should be top_priority
|
||||
score2 = self.service.compute_score(db, offline_prospect)
|
||||
assert score2.lead_tier == "top_priority"
|
||||
|
||||
def test_compute_score_updates_existing(self, db, prospect_with_score):
|
||||
"""Test that recomputing a score updates the existing record."""
|
||||
old_score_id = prospect_with_score.score.id
|
||||
score = self.service.compute_score(db, prospect_with_score)
|
||||
# Should update the same record
|
||||
assert score.id == old_score_id
|
||||
|
||||
def test_compute_all(self, db, digital_prospect, offline_prospect):
|
||||
"""Test batch score computation."""
|
||||
count = self.service.compute_all(db, limit=100)
|
||||
assert count >= 2
|
||||
36
app/modules/prospecting/tests/unit/test_stats_service.py
Normal file
36
app/modules/prospecting/tests/unit/test_stats_service.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# app/modules/prospecting/tests/unit/test_stats_service.py
|
||||
"""
|
||||
Unit tests for StatsService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.services.stats_service import StatsService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.prospecting
|
||||
class TestStatsService:
|
||||
"""Tests for StatsService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = StatsService()
|
||||
|
||||
def test_get_overview_empty(self, db):
|
||||
"""Test overview stats with no data."""
|
||||
stats = self.service.get_overview(db)
|
||||
assert "total_prospects" in stats
|
||||
assert stats["total_prospects"] == 0
|
||||
|
||||
def test_get_overview_with_data(self, db, digital_prospect, offline_prospect):
|
||||
"""Test overview stats with data."""
|
||||
stats = self.service.get_overview(db)
|
||||
assert stats["total_prospects"] >= 2
|
||||
assert stats["digital_count"] >= 1
|
||||
assert stats["offline_count"] >= 1
|
||||
|
||||
def test_get_scan_jobs_empty(self, db):
|
||||
"""Test getting scan jobs when none exist."""
|
||||
jobs, total = self.service.get_scan_jobs(db)
|
||||
assert total == 0
|
||||
assert jobs == []
|
||||
@@ -159,6 +159,7 @@ testpaths = [
|
||||
"app/modules/dev_tools/tests",
|
||||
"app/modules/monitoring/tests",
|
||||
"app/modules/analytics/tests",
|
||||
"app/modules/prospecting/tests",
|
||||
]
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
python_classes = ["Test*"]
|
||||
@@ -221,6 +222,7 @@ markers = [
|
||||
"cart: marks tests related to shopping cart module",
|
||||
"dev_tools: marks tests related to developer tools module",
|
||||
"analytics: marks tests related to analytics module",
|
||||
"prospecting: marks tests related to prospecting and lead generation module",
|
||||
"inventory_module: marks tests related to inventory module",
|
||||
# Component markers
|
||||
"service: marks tests for service layer",
|
||||
|
||||
@@ -64,6 +64,7 @@ class BaseValidator(ABC):
|
||||
".pytest_cache", ".mypy_cache", "dist", "build", "*.egg-info",
|
||||
"migrations", "alembic/versions", ".tox", "htmlcov",
|
||||
"site", # mkdocs build output
|
||||
"scripts/security-audit", # needs revamping
|
||||
]
|
||||
|
||||
# Regex for noqa comments. Supports both ruff-compatible (SEC001) and
|
||||
|
||||
Reference in New Issue
Block a user