Compare commits
25 Commits
a247622d23
...
dd09bcaeec
| Author | SHA1 | Date | |
|---|---|---|---|
| dd09bcaeec | |||
| 013eafd775 | |||
| 07cd66a0e3 | |||
| 73d453d78a | |||
| d4e9fed719 | |||
| 3e93f64c6b | |||
| 377d2d3ae8 | |||
| b51f9e8e30 | |||
| d380437594 | |||
| cff0af31be | |||
| e492e5f71c | |||
| 9a5b7dd061 | |||
| b3051b423a | |||
| bc951a36d9 | |||
| 2e043260eb | |||
| 1828ac85eb | |||
| 50a4fc38a7 | |||
| 30f3dae5a3 | |||
| 4c750f0268 | |||
| 59b0d8977a | |||
| 2bc03ed97c | |||
| 91963f3b87 | |||
| 3ae0b579d3 | |||
| 972ee1e5d0 | |||
| 70f2803dd3 |
54
app/core/preview_token.py
Normal file
54
app/core/preview_token.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# app/core/preview_token.py
|
||||
"""
|
||||
Signed preview tokens for POC site previews.
|
||||
|
||||
Generates time-limited JWT tokens that allow viewing storefront pages
|
||||
for stores without active subscriptions (POC sites). The token is
|
||||
validated by StorefrontAccessMiddleware to bypass the subscription gate.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PREVIEW_TOKEN_HOURS = 24
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def create_preview_token(store_id: int, store_code: str, site_id: int) -> str:
|
||||
"""Create a signed preview token for a POC site.
|
||||
|
||||
Token is valid for PREVIEW_TOKEN_HOURS (default 24h) and is tied
|
||||
to a specific store_id. Shareable with clients for preview access.
|
||||
"""
|
||||
payload = {
|
||||
"sub": f"preview:{store_id}",
|
||||
"store_id": store_id,
|
||||
"store_code": store_code,
|
||||
"site_id": site_id,
|
||||
"preview": True,
|
||||
"exp": datetime.now(UTC) + timedelta(hours=PREVIEW_TOKEN_HOURS),
|
||||
"iat": datetime.now(UTC),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_preview_token(token: str, store_id: int) -> bool:
|
||||
"""Verify a preview token is valid and matches the store.
|
||||
|
||||
Returns True if:
|
||||
- Token signature is valid
|
||||
- Token has not expired
|
||||
- Token has preview=True claim
|
||||
- Token store_id matches the requested store
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[ALGORITHM])
|
||||
return payload.get("preview") is True and payload.get("store_id") == store_id
|
||||
except JWTError:
|
||||
return False
|
||||
@@ -7,6 +7,9 @@
|
||||
{% from 'cms/platform/sections/_products.html' import render_products %}
|
||||
{% from 'cms/platform/sections/_features.html' import render_features %}
|
||||
{% from 'cms/platform/sections/_pricing.html' import render_pricing with context %}
|
||||
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
|
||||
{% from 'cms/platform/sections/_gallery.html' import render_gallery %}
|
||||
{% from 'cms/platform/sections/_contact_info.html' import render_contact_info %}
|
||||
{% from 'cms/platform/sections/_cta.html' import render_cta %}
|
||||
|
||||
{% block title %}
|
||||
@@ -51,6 +54,21 @@
|
||||
{{ render_pricing(page.sections.pricing, lang, default_lang, tiers) }}
|
||||
{% endif %}
|
||||
|
||||
{# Testimonials Section #}
|
||||
{% if page.sections.testimonials %}
|
||||
{{ render_testimonials(page.sections.testimonials, lang, default_lang) }}
|
||||
{% endif %}
|
||||
|
||||
{# Gallery Section #}
|
||||
{% if page.sections.gallery %}
|
||||
{{ render_gallery(page.sections.gallery, lang, default_lang) }}
|
||||
{% endif %}
|
||||
|
||||
{# Contact Info Section #}
|
||||
{% if page.sections.contact_info %}
|
||||
{{ render_contact_info(page.sections.contact_info, lang, default_lang) }}
|
||||
{% endif %}
|
||||
|
||||
{# CTA Section #}
|
||||
{% if page.sections.cta %}
|
||||
{{ render_cta(page.sections.cta, lang, default_lang) }}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{# Section partial: Contact Information #}
|
||||
{#
|
||||
Parameters:
|
||||
- contact_info: dict with enabled, title, email, phone, address, hours, map_embed_url
|
||||
- lang: Current language code
|
||||
- default_lang: Fallback language
|
||||
#}
|
||||
|
||||
{% macro render_contact_info(contact_info, lang, default_lang) %}
|
||||
{% if contact_info and contact_info.enabled %}
|
||||
<section class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
{% set title = contact_info.title.translations.get(lang) or contact_info.title.translations.get(default_lang) or 'Contact' %}
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ title }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
|
||||
{% if contact_info.phone %}
|
||||
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-purple-600 dark:text-purple-300 text-xl">📞</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Phone</h3>
|
||||
<a href="tel:{{ contact_info.phone }}" class="text-purple-600 dark:text-purple-400 hover:underline">
|
||||
{{ contact_info.phone }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if contact_info.email %}
|
||||
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-purple-600 dark:text-purple-300 text-xl">📧</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Email</h3>
|
||||
<a href="mailto:{{ contact_info.email }}" class="text-purple-600 dark:text-purple-400 hover:underline">
|
||||
{{ contact_info.email }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if contact_info.address %}
|
||||
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-purple-600 dark:text-purple-300 text-xl">📍</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Address</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{ contact_info.address }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if contact_info.hours %}
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
<span class="font-semibold">Hours:</span> {{ contact_info.hours }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{# Section partial: Image Gallery #}
|
||||
{#
|
||||
Parameters:
|
||||
- gallery: dict with enabled, title, images (list of {src, alt, caption})
|
||||
- lang: Current language code
|
||||
- default_lang: Fallback language
|
||||
#}
|
||||
|
||||
{% macro render_gallery(gallery, lang, default_lang) %}
|
||||
{% if gallery and gallery.enabled %}
|
||||
<section class="py-16 lg:py-24 bg-white dark:bg-gray-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{# Section header #}
|
||||
<div class="text-center mb-12">
|
||||
{% set title = gallery.title.translations.get(lang) or gallery.title.translations.get(default_lang) or '' %}
|
||||
{% if title %}
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ title }}
|
||||
</h2>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Image grid #}
|
||||
{% if gallery.images %}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{% for image in gallery.images %}
|
||||
<div class="relative group overflow-hidden rounded-lg aspect-square">
|
||||
<img src="{{ image.src }}"
|
||||
alt="{{ image.alt or '' }}"
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy">
|
||||
{% if image.caption %}
|
||||
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p class="text-sm text-white">{{ image.caption }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,72 @@
|
||||
{# Section partial: Testimonials #}
|
||||
{#
|
||||
Parameters:
|
||||
- testimonials: dict with enabled, title, subtitle, items
|
||||
- lang: Current language code
|
||||
- default_lang: Fallback language
|
||||
#}
|
||||
|
||||
{% macro render_testimonials(testimonials, lang, default_lang) %}
|
||||
{% if testimonials and testimonials.enabled %}
|
||||
<section class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{# Section header #}
|
||||
<div class="text-center mb-12">
|
||||
{% set title = testimonials.title.translations.get(lang) or testimonials.title.translations.get(default_lang) or '' %}
|
||||
{% if title %}
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ title }}
|
||||
</h2>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Testimonial cards #}
|
||||
{% if testimonials.items %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{% for item in testimonials.items %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex text-yellow-400">
|
||||
{% for _ in range(5) %}
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% set content = item.content %}
|
||||
{% if content is mapping %}
|
||||
{% set content = content.translations.get(lang) or content.translations.get(default_lang) or '' %}
|
||||
{% endif %}
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-6 italic">"{{ content }}"</p>
|
||||
<div class="flex items-center">
|
||||
{% if item.avatar %}
|
||||
<img src="{{ item.avatar }}" alt="" class="w-10 h-10 rounded-full mr-3">
|
||||
{% else %}
|
||||
<div class="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center mr-3">
|
||||
<span class="text-sm font-bold text-purple-600 dark:text-purple-300">
|
||||
{% set author = item.author %}
|
||||
{% if author is mapping %}{% set author = author.translations.get(lang) or author.translations.get(default_lang) or '?' %}{% endif %}
|
||||
{{ author[0]|upper if author else '?' }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
{% set author = item.author %}
|
||||
{% if author is mapping %}{% set author = author.translations.get(lang) or author.translations.get(default_lang) or '' %}{% endif %}
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ author }}</p>
|
||||
{% set role = item.role %}
|
||||
{% if role is mapping %}{% set role = role.translations.get(lang) or role.translations.get(default_lang) or '' %}{% endif %}
|
||||
{% if role %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ role }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-gray-400 dark:text-gray-500">Coming soon</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -10,6 +10,34 @@
|
||||
{% block alpine_data %}storefrontLayoutData(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||
{# SECTION-BASED RENDERING (when page.sections is configured) #}
|
||||
{# Used by POC builder templates — takes priority over hardcoded HTML #}
|
||||
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||
{% if page and page.sections %}
|
||||
{% from 'cms/platform/sections/_hero.html' import render_hero %}
|
||||
{% from 'cms/platform/sections/_features.html' import render_features %}
|
||||
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
|
||||
{% from 'cms/platform/sections/_gallery.html' import render_gallery %}
|
||||
{% from 'cms/platform/sections/_contact_info.html' import render_contact_info %}
|
||||
{% from 'cms/platform/sections/_cta.html' import render_cta %}
|
||||
|
||||
{% set lang = request.state.language|default("fr") %}
|
||||
{% set default_lang = 'fr' %}
|
||||
|
||||
<div class="min-h-screen">
|
||||
{% if page.sections.hero %}{{ render_hero(page.sections.hero, lang, default_lang) }}{% endif %}
|
||||
{% if page.sections.features %}{{ render_features(page.sections.features, lang, default_lang) }}{% endif %}
|
||||
{% if page.sections.testimonials %}{{ render_testimonials(page.sections.testimonials, lang, default_lang) }}{% endif %}
|
||||
{% if page.sections.gallery %}{{ render_gallery(page.sections.gallery, lang, default_lang) }}{% endif %}
|
||||
{% if page.sections.contact_info %}{{ render_contact_info(page.sections.contact_info, lang, default_lang) }}{% endif %}
|
||||
{% if page.sections.cta %}{{ render_cta(page.sections.cta, lang, default_lang) }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||
{# HARDCODED LAYOUT (original full landing page — no sections JSON) #}
|
||||
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||
<div class="min-h-screen">
|
||||
|
||||
{# Hero Section - Split Design #}
|
||||
@@ -255,4 +283,5 @@
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
@@ -22,13 +23,88 @@ from app.modules.hosting.schemas.hosted_site import (
|
||||
HostedSiteUpdate,
|
||||
SendProposalRequest,
|
||||
)
|
||||
from app.modules.hosting.schemas.template import TemplateListResponse, TemplateResponse
|
||||
from app.modules.hosting.services.hosted_site_service import hosted_site_service
|
||||
from app.modules.hosting.services.poc_builder_service import poc_builder_service
|
||||
from app.modules.hosting.services.template_service import template_service
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
router = APIRouter(prefix="/sites")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/templates", response_model=TemplateListResponse)
|
||||
def list_templates(
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""List available industry templates for POC site generation."""
|
||||
templates = template_service.list_templates()
|
||||
return TemplateListResponse(
|
||||
templates=[TemplateResponse(**t) for t in templates],
|
||||
)
|
||||
|
||||
|
||||
class PreviewUrlResponse(BaseModel):
|
||||
"""Response with signed preview URL."""
|
||||
|
||||
preview_url: str
|
||||
expires_in_hours: int = 24
|
||||
|
||||
|
||||
@router.get("/sites/{site_id}/preview-url", response_model=PreviewUrlResponse)
|
||||
def get_preview_url(
|
||||
site_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Generate a signed preview URL for a hosted site."""
|
||||
from app.core.preview_token import create_preview_token
|
||||
|
||||
site = hosted_site_service.get_by_id(db, site_id)
|
||||
store = site.store
|
||||
subdomain = store.subdomain or store.store_code
|
||||
token = create_preview_token(store.id, subdomain, site.id)
|
||||
return PreviewUrlResponse(
|
||||
preview_url=f"/storefront/{subdomain}/?_preview={token}",
|
||||
)
|
||||
|
||||
|
||||
class BuildPocRequest(BaseModel):
|
||||
"""Request to build a POC site from prospect + template."""
|
||||
|
||||
prospect_id: int
|
||||
template_id: str
|
||||
merchant_id: int | None = None
|
||||
|
||||
|
||||
class BuildPocResponse(BaseModel):
|
||||
"""Response from POC builder."""
|
||||
|
||||
hosted_site_id: int
|
||||
store_id: int
|
||||
pages_created: int
|
||||
theme_applied: bool
|
||||
template_id: str
|
||||
subdomain: str | None = None
|
||||
|
||||
|
||||
@router.post("/poc/build", response_model=BuildPocResponse)
|
||||
def build_poc(
|
||||
data: BuildPocRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Build a POC site from prospect data + industry template."""
|
||||
result = poc_builder_service.build_poc(
|
||||
db,
|
||||
prospect_id=data.prospect_id,
|
||||
template_id=data.template_id,
|
||||
merchant_id=data.merchant_id,
|
||||
)
|
||||
db.commit()
|
||||
return BuildPocResponse(**result)
|
||||
|
||||
|
||||
def _to_response(site) -> HostedSiteResponse:
|
||||
"""Convert a hosted site model to response schema."""
|
||||
return HostedSiteResponse(
|
||||
@@ -96,17 +172,6 @@ def create_site(
|
||||
return _to_response(site)
|
||||
|
||||
|
||||
@router.post("/from-prospect/{prospect_id}", response_model=HostedSiteResponse)
|
||||
def create_from_prospect(
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Create a hosted site pre-filled from prospect data."""
|
||||
site = hosted_site_service.create_from_prospect(db, prospect_id)
|
||||
db.commit()
|
||||
return _to_response(site)
|
||||
|
||||
|
||||
@router.put("/{site_id}", response_model=HostedSiteResponse)
|
||||
def update_site(
|
||||
|
||||
@@ -2,45 +2,73 @@
|
||||
"""
|
||||
Hosting Public Page Routes.
|
||||
|
||||
Public-facing routes for POC site viewing:
|
||||
- POC Viewer - Shows the Store's storefront with a HostWizard preview banner
|
||||
POC site preview via signed URL redirect to the storefront.
|
||||
The StorefrontAccessMiddleware validates the preview token and
|
||||
allows rendering without an active subscription.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.templates_config import templates
|
||||
from app.core.preview_token import create_preview_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/hosting/sites/{site_id}/preview",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def poc_site_viewer(
|
||||
request: Request,
|
||||
site_id: int = Path(..., description="Hosted Site ID"),
|
||||
page: str = Query("homepage", description="Page slug to preview"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Render POC site viewer with HostWizard preview banner."""
|
||||
"""Redirect to storefront with signed preview token.
|
||||
|
||||
Generates a time-limited JWT and redirects to the store's
|
||||
storefront URL. The StorefrontAccessMiddleware validates the
|
||||
token and bypasses the subscription check.
|
||||
"""
|
||||
from app.modules.hosting.models import HostedSite, HostedSiteStatus
|
||||
|
||||
site = db.query(HostedSite).filter(HostedSite.id == site_id).first()
|
||||
|
||||
# Only allow viewing for poc_ready or proposal_sent sites
|
||||
if not site or site.status not in (HostedSiteStatus.POC_READY, HostedSiteStatus.PROPOSAL_SENT):
|
||||
if not site or site.status not in (
|
||||
HostedSiteStatus.POC_READY,
|
||||
HostedSiteStatus.PROPOSAL_SENT,
|
||||
HostedSiteStatus.ACCEPTED,
|
||||
):
|
||||
return HTMLResponse(content="<h1>Site not available for preview</h1>", status_code=404)
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"site": site,
|
||||
"store_url": f"/stores/{site.store.subdomain}" if site.store else "#",
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
"hosting/public/poc-viewer.html",
|
||||
context,
|
||||
store = site.store
|
||||
if not store:
|
||||
return HTMLResponse(content="<h1>Store not found</h1>", status_code=404)
|
||||
|
||||
# Generate signed preview token — use subdomain for URL routing
|
||||
subdomain = store.subdomain or store.store_code
|
||||
token = create_preview_token(store.id, subdomain, site.id)
|
||||
|
||||
# Get platform code for dev-mode URL prefix
|
||||
from app.core.config import settings
|
||||
from app.modules.tenancy.models import StorePlatform
|
||||
|
||||
store_platform = (
|
||||
db.query(StorePlatform)
|
||||
.filter(StorePlatform.store_id == store.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
# In dev mode, storefront needs /platforms/{code}/ prefix
|
||||
if settings.debug and store_platform and store_platform.platform:
|
||||
platform_code = store_platform.platform.code
|
||||
base_url = f"/platforms/{platform_code}/storefront/{subdomain}"
|
||||
else:
|
||||
base_url = f"/storefront/{subdomain}"
|
||||
|
||||
# Append page slug — storefront needs /{slug} (root has no catch-all)
|
||||
base_url += f"/{page}"
|
||||
|
||||
return RedirectResponse(f"{base_url}?_preview={token}", status_code=302)
|
||||
|
||||
@@ -3,18 +3,31 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class HostedSiteCreate(BaseModel):
|
||||
"""Schema for creating a hosted site."""
|
||||
"""Schema for creating a hosted site.
|
||||
|
||||
Either merchant_id or prospect_id must be provided:
|
||||
- merchant_id: store is created under this merchant
|
||||
- prospect_id: a merchant is auto-created from prospect data
|
||||
"""
|
||||
|
||||
business_name: str = Field(..., max_length=255)
|
||||
merchant_id: int | None = None
|
||||
prospect_id: int | None = None
|
||||
contact_name: str | None = Field(None, max_length=255)
|
||||
contact_email: str | None = Field(None, max_length=255)
|
||||
contact_phone: str | None = Field(None, max_length=50)
|
||||
internal_notes: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_merchant_or_prospect(self) -> "HostedSiteCreate":
|
||||
if not self.merchant_id and not self.prospect_id:
|
||||
raise ValueError("Either merchant_id or prospect_id is required")
|
||||
return self
|
||||
|
||||
|
||||
class HostedSiteUpdate(BaseModel):
|
||||
"""Schema for updating a hosted site."""
|
||||
|
||||
21
app/modules/hosting/schemas/template.py
Normal file
21
app/modules/hosting/schemas/template.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# app/modules/hosting/schemas/template.py
|
||||
"""Pydantic schemas for template responses."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
"""Schema for a single template."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
tags: list[str] = []
|
||||
languages: list[str] = []
|
||||
pages: list[str] = []
|
||||
|
||||
|
||||
class TemplateListResponse(BaseModel):
|
||||
"""Schema for template list response."""
|
||||
|
||||
templates: list[TemplateResponse]
|
||||
@@ -34,12 +34,30 @@ ALLOWED_TRANSITIONS: dict[HostedSiteStatus, list[HostedSiteStatus]] = {
|
||||
}
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
"""Generate a URL-safe slug from a business name."""
|
||||
def _slugify(name: str, max_length: int = 30) -> str:
|
||||
"""Generate a short URL-safe slug from a domain or business name.
|
||||
|
||||
Priority: domain name (clean) > first 3 words of business name > full slug truncated.
|
||||
"""
|
||||
slug = name.lower().strip()
|
||||
# If it looks like a domain, extract the hostname part
|
||||
for prefix in ["https://", "http://", "www."]:
|
||||
if slug.startswith(prefix):
|
||||
slug = slug[len(prefix):]
|
||||
slug = slug.rstrip("/")
|
||||
if "." in slug and " " not in slug:
|
||||
# Domain: remove TLD → batirenovation-strasbourg.fr → batirenovation-strasbourg
|
||||
slug = slug.rsplit(".", 1)[0]
|
||||
else:
|
||||
# Business name: take first 3 meaningful words for brevity
|
||||
words = re.sub(r"[^a-z0-9\s]", "", slug).split()
|
||||
# Skip filler words
|
||||
filler = {"the", "le", "la", "les", "de", "du", "des", "et", "and", "und", "die", "der", "das"}
|
||||
words = [w for w in words if w not in filler][:3]
|
||||
slug = " ".join(words)
|
||||
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
|
||||
slug = re.sub(r"[\s-]+", "-", slug)
|
||||
return slug.strip("-")[:50]
|
||||
return slug.strip("-")[:max_length]
|
||||
|
||||
|
||||
class HostedSiteService:
|
||||
@@ -88,50 +106,47 @@ class HostedSiteService:
|
||||
return sites, total
|
||||
|
||||
def create(self, db: Session, data: dict) -> HostedSite:
|
||||
"""Create a hosted site with an auto-created Store on the hosting platform."""
|
||||
from app.modules.tenancy.models import Platform
|
||||
"""Create a hosted site with an auto-created Store on the hosting platform.
|
||||
|
||||
Requires either merchant_id or prospect_id in data:
|
||||
- merchant_id: store created under this merchant
|
||||
- prospect_id: merchant auto-created from prospect data
|
||||
"""
|
||||
from app.modules.tenancy.models import Merchant, Platform, Store
|
||||
from app.modules.tenancy.schemas.store import StoreCreate
|
||||
from app.modules.tenancy.services.admin_service import admin_service
|
||||
|
||||
business_name = data["business_name"]
|
||||
slug = _slugify(business_name)
|
||||
merchant_id = data.get("merchant_id")
|
||||
prospect_id = data.get("prospect_id")
|
||||
# Prefer domain_name for slug (shorter, cleaner), fall back to business_name
|
||||
slug_source = data.get("domain_name") or business_name
|
||||
slug = _slugify(slug_source)
|
||||
|
||||
# Find hosting platform
|
||||
platform = db.query(Platform).filter(Platform.code == "hosting").first()
|
||||
if not platform:
|
||||
raise ValueError("Hosting platform not found. Run init_production first.")
|
||||
|
||||
# Create a temporary merchant-less store requires a merchant_id.
|
||||
# For POC sites we create a placeholder: the store is re-assigned on accept_proposal.
|
||||
# Use the platform's own admin store or create under a system merchant.
|
||||
# For now, create store via AdminService which handles defaults.
|
||||
store_code = slug.upper().replace("-", "_")[:50]
|
||||
subdomain = slug
|
||||
# Resolve merchant
|
||||
if merchant_id:
|
||||
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||
if not merchant:
|
||||
raise ValueError(f"Merchant {merchant_id} not found")
|
||||
elif prospect_id:
|
||||
merchant = self._create_merchant_from_prospect(db, prospect_id, data)
|
||||
else:
|
||||
raise ValueError("Either merchant_id or prospect_id is required")
|
||||
|
||||
# Check for duplicate subdomain
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
subdomain = slug
|
||||
existing = db.query(Store).filter(Store.subdomain == subdomain).first()
|
||||
if existing:
|
||||
raise DuplicateSlugException(subdomain)
|
||||
|
||||
# We need a system merchant for POC sites.
|
||||
# Look for one or create if needed.
|
||||
from app.modules.tenancy.models import Merchant
|
||||
|
||||
system_merchant = db.query(Merchant).filter(Merchant.name == "HostWizard System").first()
|
||||
if not system_merchant:
|
||||
system_merchant = Merchant(
|
||||
name="HostWizard System",
|
||||
contact_email="system@hostwizard.lu",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(system_merchant)
|
||||
db.flush()
|
||||
|
||||
store_code = slug.upper().replace("-", "_")[:50]
|
||||
store_data = StoreCreate(
|
||||
merchant_id=system_merchant.id,
|
||||
merchant_id=merchant.id,
|
||||
store_code=store_code,
|
||||
subdomain=subdomain,
|
||||
name=business_name,
|
||||
@@ -142,7 +157,7 @@ class HostedSiteService:
|
||||
|
||||
site = HostedSite(
|
||||
store_id=store.id,
|
||||
prospect_id=data.get("prospect_id"),
|
||||
prospect_id=prospect_id,
|
||||
status=HostedSiteStatus.DRAFT,
|
||||
business_name=business_name,
|
||||
contact_name=data.get("contact_name"),
|
||||
@@ -153,12 +168,14 @@ class HostedSiteService:
|
||||
db.add(site)
|
||||
db.flush()
|
||||
|
||||
logger.info("Created hosted site: %s (store_id=%d)", site.display_name, store.id)
|
||||
logger.info("Created hosted site: %s (store_id=%d, merchant_id=%d)", site.display_name, store.id, merchant.id)
|
||||
return site
|
||||
|
||||
def create_from_prospect(self, db: Session, prospect_id: int) -> HostedSite:
|
||||
"""Create a hosted site pre-filled from prospect data."""
|
||||
def _create_merchant_from_prospect(self, db: Session, prospect_id: int, data: dict):
|
||||
"""Create a merchant from prospect data."""
|
||||
from app.modules.prospecting.models import Prospect
|
||||
from app.modules.tenancy.schemas.merchant import MerchantCreate
|
||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||
|
||||
prospect = db.query(Prospect).filter(Prospect.id == prospect_id).first()
|
||||
if not prospect:
|
||||
@@ -166,20 +183,29 @@ class HostedSiteService:
|
||||
|
||||
raise ProspectNotFoundException(str(prospect_id))
|
||||
|
||||
# Get primary contact info from prospect contacts
|
||||
# Get contact info: prefer form data, fall back to prospect contacts
|
||||
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)
|
||||
contact_name = next((c.label for c in contacts if c.label), None)
|
||||
email = (
|
||||
data.get("contact_email")
|
||||
or next((c.value for c in contacts if c.contact_type == "email"), None)
|
||||
or f"contact-{prospect_id}@hostwizard.lu"
|
||||
)
|
||||
phone = data.get("contact_phone") or next(
|
||||
(c.value for c in contacts if c.contact_type == "phone"), None
|
||||
)
|
||||
business_name = data.get("business_name") or prospect.business_name or prospect.domain_name
|
||||
|
||||
data = {
|
||||
"business_name": prospect.business_name or prospect.domain_name or f"Prospect #{prospect.id}",
|
||||
"contact_name": contact_name,
|
||||
"contact_email": primary_email,
|
||||
"contact_phone": primary_phone,
|
||||
"prospect_id": prospect.id,
|
||||
}
|
||||
return self.create(db, data)
|
||||
merchant_data = MerchantCreate(
|
||||
name=business_name,
|
||||
contact_email=email,
|
||||
contact_phone=phone,
|
||||
owner_email=email,
|
||||
)
|
||||
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
|
||||
db, merchant_data
|
||||
)
|
||||
logger.info("Created merchant %s from prospect %d", merchant.name, prospect_id)
|
||||
return merchant
|
||||
|
||||
def update(self, db: Session, site_id: int, data: dict) -> HostedSite:
|
||||
site = self.get_by_id(db, site_id)
|
||||
@@ -227,37 +253,25 @@ class HostedSiteService:
|
||||
def accept_proposal(
|
||||
self, db: Session, site_id: int, merchant_id: int | None = None
|
||||
) -> HostedSite:
|
||||
"""Accept proposal: create or link merchant, create subscription, mark converted."""
|
||||
"""Accept proposal: create subscription, mark prospect converted.
|
||||
|
||||
The merchant already exists (assigned at site creation time).
|
||||
Optionally pass merchant_id to reassign to a different merchant.
|
||||
"""
|
||||
site = self._transition(db, site_id, HostedSiteStatus.ACCEPTED)
|
||||
site.proposal_accepted_at = datetime.now(UTC)
|
||||
|
||||
from app.modules.tenancy.models import Merchant, Platform
|
||||
|
||||
# Use provided merchant_id to reassign, or keep existing store merchant
|
||||
if merchant_id:
|
||||
# Link to existing merchant
|
||||
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||
if not merchant:
|
||||
raise ValueError(f"Merchant {merchant_id} not found")
|
||||
else:
|
||||
# Create new merchant from contact info
|
||||
from app.modules.tenancy.schemas.merchant import MerchantCreate
|
||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||
|
||||
email = site.contact_email or f"contact-{site.id}@hostwizard.lu"
|
||||
merchant_data = MerchantCreate(
|
||||
name=site.business_name,
|
||||
contact_email=email,
|
||||
contact_phone=site.contact_phone,
|
||||
owner_email=email,
|
||||
)
|
||||
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
|
||||
db, merchant_data
|
||||
)
|
||||
logger.info("Created merchant %s for site %d", merchant.name, site_id)
|
||||
|
||||
# Re-assign store to the real merchant
|
||||
site.store.merchant_id = merchant.id
|
||||
db.flush()
|
||||
else:
|
||||
merchant = site.store.merchant
|
||||
|
||||
# Create MerchantSubscription on hosting platform
|
||||
platform = db.query(Platform).filter(Platform.code == "hosting").first()
|
||||
@@ -286,7 +300,6 @@ class HostedSiteService:
|
||||
prospect = db.query(Prospect).filter(Prospect.id == site.prospect_id).first()
|
||||
if prospect and prospect.status != ProspectStatus.CONVERTED:
|
||||
prospect.status = ProspectStatus.CONVERTED
|
||||
db.flush()
|
||||
|
||||
db.flush()
|
||||
logger.info("Proposal accepted for site %d (merchant=%d)", site_id, merchant.id)
|
||||
|
||||
253
app/modules/hosting/services/poc_builder_service.py
Normal file
253
app/modules/hosting/services/poc_builder_service.py
Normal file
@@ -0,0 +1,253 @@
|
||||
# app/modules/hosting/services/poc_builder_service.py
|
||||
"""
|
||||
POC Builder Service — creates a near-final multi-page website from
|
||||
a prospect + industry template.
|
||||
|
||||
Flow:
|
||||
1. Load prospect data (scraped content, contacts)
|
||||
2. Load industry template (pages, theme)
|
||||
3. Create HostedSite + Store via hosted_site_service
|
||||
4. Populate CMS ContentPages from template, replacing {{placeholders}}
|
||||
with prospect data
|
||||
5. Apply StoreTheme from template
|
||||
6. Result: a previewable site at {subdomain}.hostwizard.lu
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.hosting.services.hosted_site_service import hosted_site_service
|
||||
from app.modules.hosting.services.template_service import template_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PocBuilderService:
|
||||
"""Builds POC sites from prospect data + industry templates."""
|
||||
|
||||
def build_poc(
|
||||
self,
|
||||
db: Session,
|
||||
prospect_id: int,
|
||||
template_id: str,
|
||||
merchant_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Build a complete POC site from prospect data and a template.
|
||||
|
||||
Returns dict with hosted_site, store, pages_created, theme_applied.
|
||||
"""
|
||||
from app.modules.prospecting.models import Prospect
|
||||
|
||||
# 1. Load prospect
|
||||
prospect = db.query(Prospect).filter(Prospect.id == prospect_id).first()
|
||||
if not prospect:
|
||||
from app.modules.prospecting.exceptions import ProspectNotFoundException
|
||||
|
||||
raise ProspectNotFoundException(str(prospect_id))
|
||||
|
||||
# 2. Load template
|
||||
template = template_service.get_template(template_id)
|
||||
if not template:
|
||||
raise ValueError(f"Template '{template_id}' not found")
|
||||
|
||||
# 3. Build placeholder context from prospect data
|
||||
context = self._build_context(prospect)
|
||||
|
||||
# 4. Create HostedSite + Store
|
||||
site_data = {
|
||||
"business_name": context["business_name"],
|
||||
"domain_name": prospect.domain_name, # used for clean subdomain slug
|
||||
"prospect_id": prospect_id,
|
||||
"contact_email": context.get("email"),
|
||||
"contact_phone": context.get("phone"),
|
||||
}
|
||||
if merchant_id:
|
||||
site_data["merchant_id"] = merchant_id
|
||||
|
||||
site = hosted_site_service.create(db, site_data)
|
||||
|
||||
# 5. Get the hosting platform_id from the store
|
||||
from app.modules.tenancy.models import StorePlatform
|
||||
|
||||
store_platform = (
|
||||
db.query(StorePlatform)
|
||||
.filter(StorePlatform.store_id == site.store_id)
|
||||
.first()
|
||||
)
|
||||
platform_id = store_platform.platform_id if store_platform else None
|
||||
|
||||
if not platform_id:
|
||||
logger.warning("No platform found for store %d", site.store_id)
|
||||
|
||||
# 6. Populate CMS ContentPages from template
|
||||
pages_created = 0
|
||||
if platform_id:
|
||||
pages_created = self._create_pages(db, site.store_id, platform_id, template, context)
|
||||
|
||||
# 7. Apply StoreTheme
|
||||
theme_applied = self._apply_theme(db, site.store_id, template)
|
||||
|
||||
# 8. Mark POC ready
|
||||
hosted_site_service.mark_poc_ready(db, site.id)
|
||||
|
||||
db.flush()
|
||||
logger.info(
|
||||
"POC built for prospect %d: site=%d, store=%d, %d pages, template=%s",
|
||||
prospect_id, site.id, site.store_id, pages_created, template_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"hosted_site_id": site.id,
|
||||
"store_id": site.store_id,
|
||||
"pages_created": pages_created,
|
||||
"theme_applied": theme_applied,
|
||||
"template_id": template_id,
|
||||
"subdomain": site.store.subdomain if site.store else None,
|
||||
}
|
||||
|
||||
def _build_context(self, prospect) -> dict:
|
||||
"""Build placeholder replacement context from prospect data."""
|
||||
# Base context
|
||||
context = {
|
||||
"business_name": prospect.business_name or prospect.domain_name or "My Business",
|
||||
"domain": prospect.domain_name or "",
|
||||
"city": prospect.city or "",
|
||||
"address": "",
|
||||
"email": "",
|
||||
"phone": "",
|
||||
"meta_description": "",
|
||||
"about_paragraph": "",
|
||||
}
|
||||
|
||||
# From contacts
|
||||
contacts = prospect.contacts or []
|
||||
for c in contacts:
|
||||
if c.contact_type == "email" and not context["email"]:
|
||||
context["email"] = c.value
|
||||
elif c.contact_type == "phone" and not context["phone"]:
|
||||
context["phone"] = c.value
|
||||
elif c.contact_type == "address" and not context["address"]:
|
||||
context["address"] = c.value
|
||||
|
||||
# From scraped content
|
||||
if prospect.scraped_content_json:
|
||||
try:
|
||||
scraped = json.loads(prospect.scraped_content_json)
|
||||
if scraped.get("meta_description"):
|
||||
context["meta_description"] = scraped["meta_description"]
|
||||
if scraped.get("paragraphs"):
|
||||
context["about_paragraph"] = scraped["paragraphs"][0]
|
||||
if scraped.get("headings") and not prospect.business_name:
|
||||
context["business_name"] = scraped["headings"][0]
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# From prospect fields
|
||||
if prospect.city:
|
||||
context["city"] = prospect.city
|
||||
elif context["address"]:
|
||||
# Try to extract city from address (last word after postal code)
|
||||
parts = context["address"].split()
|
||||
if len(parts) >= 2:
|
||||
context["city"] = parts[-1]
|
||||
|
||||
return context
|
||||
|
||||
def _replace_placeholders(self, text: str, context: dict) -> str:
|
||||
"""Replace {{placeholder}} variables in text with context values."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
def replacer(match):
|
||||
key = match.group(1).strip()
|
||||
return context.get(key, match.group(0))
|
||||
|
||||
return re.sub(r"\{\{(\w+)\}\}", replacer, text)
|
||||
|
||||
def _replace_in_structure(self, data, context: dict):
|
||||
"""Recursively replace placeholders in a nested dict/list structure."""
|
||||
if isinstance(data, str):
|
||||
return self._replace_placeholders(data, context)
|
||||
if isinstance(data, dict):
|
||||
return {k: self._replace_in_structure(v, context) for k, v in data.items()}
|
||||
if isinstance(data, list):
|
||||
return [self._replace_in_structure(item, context) for item in data]
|
||||
return data
|
||||
|
||||
def _create_pages(self, db: Session, store_id: int, platform_id: int, template: dict, context: dict) -> int:
|
||||
"""Create CMS ContentPages from template page definitions."""
|
||||
from app.modules.cms.models.content_page import ContentPage
|
||||
|
||||
count = 0
|
||||
for page_def in template.get("pages", []):
|
||||
slug = page_def.get("slug", "")
|
||||
if not slug:
|
||||
continue
|
||||
|
||||
# Replace placeholders in all text fields
|
||||
page_data = self._replace_in_structure(page_def, context)
|
||||
|
||||
# Build content from content_translations if present
|
||||
content = page_data.get("content", "")
|
||||
content_translations = page_data.get("content_translations")
|
||||
if content_translations and not content:
|
||||
content = next(iter(content_translations.values()), "")
|
||||
|
||||
page = ContentPage(
|
||||
platform_id=platform_id,
|
||||
store_id=store_id,
|
||||
is_platform_page=False,
|
||||
slug=slug,
|
||||
title=page_data.get("title", slug.title()),
|
||||
content=content or f"<p>{slug.title()} page content</p>",
|
||||
content_format="html",
|
||||
template=page_data.get("template", "default"),
|
||||
sections=page_data.get("sections"),
|
||||
title_translations=page_data.get("title_translations"),
|
||||
content_translations=content_translations,
|
||||
meta_description=context.get("meta_description"),
|
||||
is_published=page_data.get("is_published", True),
|
||||
published_at=datetime.now(UTC) if page_data.get("is_published", True) else None,
|
||||
show_in_header=page_data.get("show_in_header", False),
|
||||
show_in_footer=page_data.get("show_in_footer", False),
|
||||
)
|
||||
db.add(page)
|
||||
count += 1
|
||||
|
||||
db.flush()
|
||||
return count
|
||||
|
||||
def _apply_theme(self, db: Session, store_id: int, template: dict) -> bool:
|
||||
"""Apply the template's theme to the store."""
|
||||
from app.modules.cms.models.store_theme import StoreTheme
|
||||
|
||||
theme_data = template.get("theme")
|
||||
if not theme_data:
|
||||
return False
|
||||
|
||||
# Check if store already has a theme
|
||||
existing = db.query(StoreTheme).filter(StoreTheme.store_id == store_id).first()
|
||||
if existing:
|
||||
# Update existing
|
||||
theme = existing
|
||||
else:
|
||||
theme = StoreTheme(store_id=store_id)
|
||||
db.add(theme)
|
||||
|
||||
colors = theme_data.get("colors", {})
|
||||
theme.theme_name = theme_data.get("theme_name", "default")
|
||||
theme.colors = colors
|
||||
theme.font_family_heading = theme_data.get("font_family_heading", "Inter")
|
||||
theme.font_family_body = theme_data.get("font_family_body", "Inter")
|
||||
theme.layout_style = theme_data.get("layout_style", "grid")
|
||||
theme.header_style = theme_data.get("header_style", "fixed")
|
||||
|
||||
db.flush()
|
||||
return True
|
||||
|
||||
|
||||
poc_builder_service = PocBuilderService()
|
||||
114
app/modules/hosting/services/template_service.py
Normal file
114
app/modules/hosting/services/template_service.py
Normal file
@@ -0,0 +1,114 @@
|
||||
# app/modules/hosting/services/template_service.py
|
||||
"""
|
||||
Template service for the hosting module.
|
||||
|
||||
Loads and manages industry templates from the templates_library directory.
|
||||
Templates are JSON files that define page content, themes, and sections
|
||||
for different business types (restaurant, construction, etc.).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates_library"
|
||||
|
||||
|
||||
class TemplateService:
|
||||
"""Manages industry templates for POC site generation."""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = None
|
||||
self._cache: dict[str, dict] = {}
|
||||
|
||||
def _load_manifest(self) -> dict:
|
||||
"""Load the manifest.json file."""
|
||||
if self._manifest is None:
|
||||
manifest_path = TEMPLATES_DIR / "manifest.json"
|
||||
self._manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
return self._manifest
|
||||
|
||||
def list_templates(self) -> list[dict]:
|
||||
"""List all available templates with metadata."""
|
||||
manifest = self._load_manifest()
|
||||
templates = []
|
||||
for entry in manifest.get("templates", []):
|
||||
template_id = entry["id"]
|
||||
meta = self._load_meta(template_id)
|
||||
templates.append({
|
||||
"id": template_id,
|
||||
"name": meta.get("name", entry.get("name", template_id)),
|
||||
"description": meta.get("description", entry.get("description", "")),
|
||||
"tags": meta.get("tags", entry.get("tags", [])),
|
||||
"languages": meta.get("languages", []),
|
||||
"pages": entry.get("pages", []),
|
||||
})
|
||||
return templates
|
||||
|
||||
def get_template(self, template_id: str) -> dict | None:
|
||||
"""Load a complete template with meta, theme, and all pages."""
|
||||
if template_id in self._cache:
|
||||
return self._cache[template_id]
|
||||
|
||||
template_dir = TEMPLATES_DIR / template_id
|
||||
if not template_dir.is_dir():
|
||||
return None
|
||||
|
||||
meta = self._load_meta(template_id)
|
||||
theme = self._load_json(template_dir / "theme.json")
|
||||
pages = self._load_pages(template_dir)
|
||||
|
||||
template = {
|
||||
"id": template_id,
|
||||
"meta": meta,
|
||||
"theme": theme,
|
||||
"pages": pages,
|
||||
}
|
||||
self._cache[template_id] = template
|
||||
return template
|
||||
|
||||
def get_theme(self, template_id: str) -> dict | None:
|
||||
"""Load just the theme configuration for a template."""
|
||||
template_dir = TEMPLATES_DIR / template_id
|
||||
return self._load_json(template_dir / "theme.json")
|
||||
|
||||
def get_page(self, template_id: str, page_slug: str) -> dict | None:
|
||||
"""Load a single page definition from a template."""
|
||||
page_path = TEMPLATES_DIR / template_id / "pages" / f"{page_slug}.json"
|
||||
return self._load_json(page_path)
|
||||
|
||||
def template_exists(self, template_id: str) -> bool:
|
||||
"""Check if a template exists."""
|
||||
return (TEMPLATES_DIR / template_id / "meta.json").is_file()
|
||||
|
||||
def _load_meta(self, template_id: str) -> dict:
|
||||
"""Load meta.json for a template."""
|
||||
return self._load_json(TEMPLATES_DIR / template_id / "meta.json") or {}
|
||||
|
||||
def _load_pages(self, template_dir: Path) -> list[dict]:
|
||||
"""Load all page JSONs from a template's pages/ directory."""
|
||||
pages_dir = template_dir / "pages"
|
||||
if not pages_dir.is_dir():
|
||||
return []
|
||||
pages = []
|
||||
for page_file in sorted(pages_dir.glob("*.json")):
|
||||
page_data = self._load_json(page_file)
|
||||
if page_data:
|
||||
pages.append(page_data)
|
||||
return pages
|
||||
|
||||
@staticmethod
|
||||
def _load_json(path: Path) -> dict | None:
|
||||
"""Safely load a JSON file."""
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to load template file %s: %s", path, e)
|
||||
return None
|
||||
|
||||
|
||||
template_service = TemplateService()
|
||||
@@ -12,7 +12,8 @@
|
||||
{{ loading_state('Loading site...') }}
|
||||
{{ error_state('Error loading site') }}
|
||||
|
||||
<div x-show="!loading && !error && site" class="space-y-6">
|
||||
<template x-if="!loading && !error && site">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between my-6 gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
@@ -38,6 +39,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Build POC (draft sites only) -->
|
||||
<div x-show="site.status === 'draft' && site.prospect_id" class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Build POC from Template</h3>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<select x-model="selectedTemplate"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-teal-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">Select a template...</option>
|
||||
<template x-for="t in templates" :key="t.id">
|
||||
<option :value="t.id" x-text="t.name + ' — ' + t.description"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" @click="buildPoc()" :disabled="!selectedTemplate || buildingPoc"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none disabled:opacity-50">
|
||||
<span x-show="!buildingPoc" x-html="$icon('sparkles', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="buildingPoc" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="buildingPoc ? 'Building...' : 'Build POC'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p x-show="pocResult" class="mt-2 text-sm text-green-600" x-text="pocResult"></p>
|
||||
</div>
|
||||
|
||||
<!-- Lifecycle Actions -->
|
||||
<div class="flex flex-wrap gap-3 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<button type="button" x-show="site.status === 'draft'" @click="doAction('mark-poc-ready')"
|
||||
@@ -187,6 +211,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Send Proposal Modal -->
|
||||
{% call modal('proposalModal', 'Send Proposal', show_var='showProposalModal', size='md', show_footer=false) %}
|
||||
@@ -305,7 +330,39 @@ function hostingSiteDetail(siteId) {
|
||||
acceptMerchantId: '',
|
||||
goLiveDomain: '',
|
||||
newService: { service_type: 'domain', name: '', price_cents: null, billing_period: 'monthly' },
|
||||
async init() { await this.loadSite(); },
|
||||
// POC builder
|
||||
templates: [],
|
||||
selectedTemplate: '',
|
||||
buildingPoc: false,
|
||||
pocResult: '',
|
||||
async init() {
|
||||
await this.loadSite();
|
||||
await this.loadTemplates();
|
||||
},
|
||||
async loadTemplates() {
|
||||
try {
|
||||
var resp = await apiClient.get('/admin/hosting/sites/templates');
|
||||
this.templates = resp.templates || [];
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
async buildPoc() {
|
||||
if (!this.selectedTemplate || !this.site.prospect_id) return;
|
||||
this.buildingPoc = true;
|
||||
this.pocResult = '';
|
||||
try {
|
||||
var result = await apiClient.post('/admin/hosting/sites/poc/build', {
|
||||
prospect_id: this.site.prospect_id,
|
||||
template_id: this.selectedTemplate,
|
||||
});
|
||||
this.pocResult = 'POC built! ' + result.pages_created + ' pages created.';
|
||||
Utils.showToast('POC built successfully', 'success');
|
||||
await this.loadSite();
|
||||
} catch (e) {
|
||||
Utils.showToast('Build failed: ' + e.message, 'error');
|
||||
} finally {
|
||||
this.buildingPoc = false;
|
||||
}
|
||||
},
|
||||
async loadSite() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
@@ -44,18 +44,48 @@
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Prospect Selector -->
|
||||
<!-- Prospect Search -->
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Create from Prospect (optional)</label>
|
||||
<div class="flex mt-1 space-x-2">
|
||||
<input type="number" x-model="prospectId" placeholder="Prospect ID" {# noqa: FE008 - prospect ID input #}
|
||||
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
|
||||
<button type="button" @click="createFromProspect()"
|
||||
:disabled="!prospectId || creating"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Create from Prospect
|
||||
</button>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Link to Prospect <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="text" x-model="prospectSearch" @input.debounce.300ms="searchProspects()"
|
||||
@focus="showProspectDropdown = true"
|
||||
placeholder="Search by domain or business name..."
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<!-- Selected prospect badge -->
|
||||
<div x-show="selectedProspect" class="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-teal-100 text-teal-700 dark:bg-teal-900 dark:text-teal-300">
|
||||
<span x-text="'#' + form.prospect_id"></span>
|
||||
<button type="button" @click="clearProspect()" class="ml-1 text-teal-500 hover:text-teal-700">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Dropdown -->
|
||||
<div x-show="showProspectDropdown && prospectResults.length > 0" @click.away="showProspectDropdown = false"
|
||||
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-auto">
|
||||
<template x-for="p in prospectResults" :key="p.id">
|
||||
<button type="button" @click="selectProspect(p)"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-600 flex justify-between items-center">
|
||||
<div>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200" x-text="p.business_name || p.domain_name"></span>
|
||||
<span x-show="p.domain_name && p.business_name" class="text-xs text-gray-400 ml-2" x-text="p.domain_name"></span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400" x-text="'#' + p.id"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">A merchant will be auto-created from the prospect's contact data.</p>
|
||||
</div>
|
||||
|
||||
<!-- Optional: Existing Merchant -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Or link to existing Merchant ID <span class="text-xs text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<input type="number" x-model.number="form.merchant_id" placeholder="Leave empty to auto-create" {# noqa: FE008 #}
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,10 +96,8 @@
|
||||
Cancel
|
||||
</a>
|
||||
<button type="button" @click="createSite()"
|
||||
:disabled="!form.business_name || creating"
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!creating" x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="creating" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
:disabled="!canCreate || creating"
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-text="creating ? 'Creating...' : 'Create Site'"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -85,15 +113,76 @@ function hostingSiteNew() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'hosting-sites',
|
||||
form: { business_name: '', contact_name: '', contact_email: '', contact_phone: '', internal_notes: '' },
|
||||
prospectId: '',
|
||||
form: {
|
||||
business_name: '',
|
||||
prospect_id: null,
|
||||
merchant_id: null,
|
||||
contact_name: '',
|
||||
contact_email: '',
|
||||
contact_phone: '',
|
||||
internal_notes: '',
|
||||
},
|
||||
// Prospect search
|
||||
prospectSearch: '',
|
||||
prospectResults: [],
|
||||
selectedProspect: null,
|
||||
showProspectDropdown: false,
|
||||
|
||||
creating: false,
|
||||
errorMsg: '',
|
||||
|
||||
get canCreate() {
|
||||
return this.form.business_name && (this.form.prospect_id || this.form.merchant_id);
|
||||
},
|
||||
|
||||
async searchProspects() {
|
||||
if (this.prospectSearch.length < 2) { this.prospectResults = []; return; }
|
||||
try {
|
||||
var resp = await apiClient.get('/admin/prospecting/prospects?search=' + encodeURIComponent(this.prospectSearch) + '&per_page=10');
|
||||
this.prospectResults = resp.items || [];
|
||||
this.showProspectDropdown = true;
|
||||
} catch (e) {
|
||||
this.prospectResults = [];
|
||||
}
|
||||
},
|
||||
|
||||
selectProspect(prospect) {
|
||||
this.selectedProspect = prospect;
|
||||
this.form.prospect_id = prospect.id;
|
||||
this.prospectSearch = prospect.business_name || prospect.domain_name;
|
||||
this.showProspectDropdown = false;
|
||||
// Auto-fill form from prospect
|
||||
if (!this.form.business_name) {
|
||||
this.form.business_name = prospect.business_name || prospect.domain_name || '';
|
||||
}
|
||||
if (!this.form.contact_email && prospect.primary_email) {
|
||||
this.form.contact_email = prospect.primary_email;
|
||||
}
|
||||
if (!this.form.contact_phone && prospect.primary_phone) {
|
||||
this.form.contact_phone = prospect.primary_phone;
|
||||
}
|
||||
},
|
||||
|
||||
clearProspect() {
|
||||
this.selectedProspect = null;
|
||||
this.form.prospect_id = null;
|
||||
this.prospectSearch = '';
|
||||
this.prospectResults = [];
|
||||
},
|
||||
|
||||
async createSite() {
|
||||
if (!this.canCreate) {
|
||||
this.errorMsg = 'Business name and a linked prospect or merchant are required';
|
||||
return;
|
||||
}
|
||||
this.creating = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const site = await apiClient.post('/admin/hosting/sites', this.form);
|
||||
var payload = {};
|
||||
for (var k in this.form) {
|
||||
if (this.form[k] !== null && this.form[k] !== '') payload[k] = this.form[k];
|
||||
}
|
||||
const site = await apiClient.post('/admin/hosting/sites', payload);
|
||||
window.location.href = '/admin/hosting/sites/' + site.id;
|
||||
} catch (e) {
|
||||
this.errorMsg = e.message || 'Failed to create site';
|
||||
@@ -101,18 +190,6 @@ function hostingSiteNew() {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
async createFromProspect() {
|
||||
this.creating = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const site = await apiClient.post('/admin/hosting/sites/from-prospect/' + this.prospectId);
|
||||
window.location.href = '/admin/hosting/sites/' + site.id;
|
||||
} catch (e) {
|
||||
this.errorMsg = e.message || 'Failed to create from prospect';
|
||||
} finally {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -40,11 +40,6 @@
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
|
||||
<a href="/admin/prospecting/prospects"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-teal-700 dark:text-teal-300 transition-colors duration-150 bg-teal-100 dark:bg-teal-900 border border-transparent rounded-lg hover:bg-teal-200 dark:hover:bg-teal-800 focus:outline-none">
|
||||
<span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span>
|
||||
Create from Prospect
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,6 +90,11 @@
|
||||
title="View details">
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
<button type="button" @click="deleteSite(s)"
|
||||
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||
title="Delete">
|
||||
<span x-html="$icon('trash', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -144,6 +144,16 @@ function hostingSitesList() {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async deleteSite(site) {
|
||||
if (!confirm('Delete "' + site.business_name + '"? This will also delete the associated store.')) return;
|
||||
try {
|
||||
await apiClient.delete('/admin/hosting/sites/' + site.id);
|
||||
Utils.showToast('Site deleted', 'success');
|
||||
await this.loadSites();
|
||||
} catch (e) {
|
||||
Utils.showToast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
get startIndex() {
|
||||
if (this.pagination.total === 0) return 0;
|
||||
return (this.pagination.page - 1) * this.pagination.per_page + 1;
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ site.business_name }} - Preview by HostWizard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
.hw-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
background: linear-gradient(135deg, #0D9488, #14B8A6);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
.hw-banner-left { display: flex; align-items: center; gap: 12px; }
|
||||
.hw-banner-logo { font-weight: 700; font-size: 16px; }
|
||||
.hw-banner-text { opacity: 0.9; }
|
||||
.hw-banner-right { display: flex; align-items: center; gap: 12px; }
|
||||
.hw-banner-link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 6px 16px;
|
||||
border: 1px solid rgba(255,255,255,0.4);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.hw-banner-link:hover { background: rgba(255,255,255,0.15); }
|
||||
.hw-iframe-container {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
.hw-iframe-container iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="hw-banner">
|
||||
<div class="hw-banner-left">
|
||||
<span class="hw-banner-logo">HostWizard</span>
|
||||
<span class="hw-banner-text">Preview for {{ site.business_name }}</span>
|
||||
</div>
|
||||
<div class="hw-banner-right">
|
||||
<a href="https://hostwizard.lu" class="hw-banner-link" target="_blank">hostwizard.lu</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hw-iframe-container">
|
||||
<iframe src="{{ store_url }}" title="Site preview"></iframe>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
{"id": "auto-parts", "name": "Auto Parts & Garage", "description": "Template for auto parts shops, garages, and car dealers", "tags": ["automotive", "garage", "car", "parts"], "languages": ["en", "fr", "de"]}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"slug": "catalog",
|
||||
"title": "Catalog",
|
||||
"title_translations": {"en": "Parts Catalog", "fr": "Catalogue de pièces", "de": "Teilekatalog"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Parts Catalog</h2>\n<p>Browse our extensive catalog of auto parts for all major brands.</p>",
|
||||
"fr": "<h2>Catalogue de pièces</h2>\n<p>Parcourez notre catalogue complet de pièces auto pour toutes les grandes marques.</p>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"slug": "contact",
|
||||
"title": "Contact",
|
||||
"title_translations": {"en": "Contact Us", "fr": "Contactez-nous", "de": "Kontakt"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"show_in_footer": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Contact Us</h2>\n<p>Visit our store or get in touch for parts inquiries.</p>\n<ul>\n<li>Phone: {{phone}}</li>\n<li>Email: {{email}}</li>\n<li>Address: {{address}}</li>\n</ul>",
|
||||
"fr": "<h2>Contactez-nous</h2>\n<p>Visitez notre magasin ou contactez-nous pour vos demandes de pièces.</p>\n<ul>\n<li>Téléphone : {{phone}}</li>\n<li>Email : {{email}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"slug": "homepage",
|
||||
"title": "{{business_name}}",
|
||||
"template": "full",
|
||||
"is_published": true,
|
||||
"sections": {
|
||||
"hero": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
|
||||
"subtitle": {"translations": {"en": "Your trusted auto parts specialist in {{city}}", "fr": "Votre spécialiste pièces auto de confiance à {{city}}"}},
|
||||
"background_type": "image",
|
||||
"buttons": [
|
||||
{"label": {"translations": {"en": "Browse Parts", "fr": "Voir les pièces"}}, "url": "/catalog", "style": "primary"},
|
||||
{"label": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "secondary"}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Why Choose Us", "fr": "Pourquoi nous choisir"}},
|
||||
"items": [
|
||||
{"icon": "truck", "title": {"translations": {"en": "Fast Delivery", "fr": "Livraison rapide"}}, "description": {"translations": {"en": "Same-day delivery on in-stock parts", "fr": "Livraison le jour même pour les pièces en stock"}}},
|
||||
{"icon": "shield-check", "title": {"translations": {"en": "Quality Guaranteed", "fr": "Qualité garantie"}}, "description": {"translations": {"en": "OEM and certified aftermarket parts", "fr": "Pièces OEM et aftermarket certifiées"}}},
|
||||
{"icon": "currency-euro", "title": {"translations": {"en": "Best Prices", "fr": "Meilleurs prix"}}, "description": {"translations": {"en": "Competitive pricing on all brands", "fr": "Prix compétitifs sur toutes les marques"}}}
|
||||
]
|
||||
},
|
||||
"cta": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Need a specific part?", "fr": "Besoin d'une pièce spécifique ?"}},
|
||||
"buttons": [
|
||||
{"label": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "primary"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"theme_name": "modern",
|
||||
"colors": {"primary": "#dc2626", "secondary": "#991b1b", "accent": "#f59e0b", "background": "#fafafa", "text": "#18181b", "border": "#e4e4e7"},
|
||||
"font_family_heading": "Montserrat",
|
||||
"font_family_body": "Inter",
|
||||
"layout_style": "grid",
|
||||
"header_style": "fixed"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"id": "construction", "name": "Construction & Renovation", "description": "Professional template for builders, renovators, and tradespeople", "tags": ["construction", "renovation", "building", "trades"], "languages": ["en", "fr", "de"]}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"slug": "contact",
|
||||
"title": "Contact",
|
||||
"title_translations": {"en": "Contact Us", "fr": "Contactez-nous", "de": "Kontakt"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"show_in_footer": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Get a Free Quote</h2>\n<p>Tell us about your project and we'll get back to you within 24 hours.</p>\n<ul>\n<li>Phone: {{phone}}</li>\n<li>Email: {{email}}</li>\n<li>Address: {{address}}</li>\n</ul>",
|
||||
"fr": "<h2>Demandez un devis gratuit</h2>\n<p>Décrivez-nous votre projet et nous vous recontacterons sous 24h.</p>\n<ul>\n<li>Téléphone : {{phone}}</li>\n<li>Email : {{email}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"slug": "homepage",
|
||||
"title": "{{business_name}}",
|
||||
"template": "full",
|
||||
"is_published": true,
|
||||
"sections": {
|
||||
"hero": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
|
||||
"subtitle": {"translations": {"en": "Quality construction and renovation in {{city}}", "fr": "Construction et rénovation de qualité à {{city}}"}},
|
||||
"background_type": "image",
|
||||
"buttons": [
|
||||
{"label": {"translations": {"en": "Get a Free Quote", "fr": "Devis gratuit"}}, "url": "/contact", "style": "primary"},
|
||||
{"label": {"translations": {"en": "Our Projects", "fr": "Nos réalisations"}}, "url": "/projects", "style": "secondary"}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Our Services", "fr": "Nos Services"}},
|
||||
"items": [
|
||||
{"icon": "home", "title": {"translations": {"en": "New Construction", "fr": "Construction neuve"}}, "description": {"translations": {"en": "Custom-built homes and commercial buildings", "fr": "Maisons et bâtiments commerciaux sur mesure"}}},
|
||||
{"icon": "wrench", "title": {"translations": {"en": "Renovation", "fr": "Rénovation"}}, "description": {"translations": {"en": "Complete interior and exterior renovation", "fr": "Rénovation complète intérieure et extérieure"}}},
|
||||
{"icon": "color-swatch", "title": {"translations": {"en": "Painting & Finishing", "fr": "Peinture & Finitions"}}, "description": {"translations": {"en": "Professional painting and finishing work", "fr": "Travaux de peinture et finitions professionnels"}}},
|
||||
{"icon": "shield-check", "title": {"translations": {"en": "Insulation", "fr": "Isolation"}}, "description": {"translations": {"en": "Energy-efficient insulation solutions", "fr": "Solutions d'isolation éco-énergétiques"}}}
|
||||
]
|
||||
},
|
||||
"testimonials": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "What Our Clients Say", "fr": "Témoignages de nos clients"}},
|
||||
"items": []
|
||||
},
|
||||
"cta": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Ready to start your project?", "fr": "Prêt à démarrer votre projet ?"}},
|
||||
"buttons": [
|
||||
{"label": {"translations": {"en": "Request a Quote", "fr": "Demander un devis"}}, "url": "/contact", "style": "primary"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"slug": "projects",
|
||||
"title": "Projects",
|
||||
"title_translations": {"en": "Our Projects", "fr": "Nos Réalisations", "de": "Unsere Projekte"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Our Projects</h2>\n<p>Browse our portfolio of completed construction and renovation projects.</p>",
|
||||
"fr": "<h2>Nos Réalisations</h2>\n<p>Découvrez notre portfolio de projets de construction et rénovation réalisés.</p>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"slug": "services",
|
||||
"title": "Services",
|
||||
"title_translations": {"en": "Our Services", "fr": "Nos Services", "de": "Unsere Leistungen"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Our Services</h2>\n<p>We offer a comprehensive range of construction and renovation services.</p>\n<h3>Construction</h3>\n<p>From foundations to finishing touches, we handle every aspect of new builds.</p>\n<h3>Renovation</h3>\n<p>Transform your existing space with our expert renovation team.</p>\n<h3>Painting & Decoration</h3>\n<p>Professional interior and exterior painting services.</p>",
|
||||
"fr": "<h2>Nos Services</h2>\n<p>Nous proposons une gamme complète de services de construction et rénovation.</p>\n<h3>Construction</h3>\n<p>Des fondations aux finitions, nous gérons chaque aspect des constructions neuves.</p>\n<h3>Rénovation</h3>\n<p>Transformez votre espace avec notre équipe de rénovation experte.</p>\n<h3>Peinture & Décoration</h3>\n<p>Services professionnels de peinture intérieure et extérieure.</p>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"theme_name": "modern",
|
||||
"colors": {
|
||||
"primary": "#d97706",
|
||||
"secondary": "#92400e",
|
||||
"accent": "#fbbf24",
|
||||
"background": "#fafaf9",
|
||||
"text": "#1c1917",
|
||||
"border": "#d6d3d1"
|
||||
},
|
||||
"font_family_heading": "Montserrat",
|
||||
"font_family_body": "Open Sans",
|
||||
"layout_style": "grid",
|
||||
"header_style": "fixed"
|
||||
}
|
||||
7
app/modules/hosting/templates_library/generic/meta.json
Normal file
7
app/modules/hosting/templates_library/generic/meta.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "generic",
|
||||
"name": "Generic Business",
|
||||
"description": "Clean, minimal template that works for any business type",
|
||||
"tags": ["general", "minimal", "any"],
|
||||
"languages": ["en", "fr", "de"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"slug": "about",
|
||||
"title": "About Us",
|
||||
"title_translations": {"en": "About Us", "fr": "À propos", "de": "Über uns"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"content": "{{about_content}}",
|
||||
"content_translations": {
|
||||
"en": "<h2>About {{business_name}}</h2>\n<p>{{about_paragraph}}</p>",
|
||||
"fr": "<h2>À propos de {{business_name}}</h2>\n<p>{{about_paragraph}}</p>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"slug": "contact",
|
||||
"title": "Contact",
|
||||
"title_translations": {"en": "Contact Us", "fr": "Contact", "de": "Kontakt"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"show_in_footer": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Get in Touch</h2>\n<p>We'd love to hear from you. Reach out using the information below.</p>\n<ul>\n<li>Email: {{email}}</li>\n<li>Phone: {{phone}}</li>\n<li>Address: {{address}}</li>\n</ul>",
|
||||
"fr": "<h2>Contactez-nous</h2>\n<p>N'hésitez pas à nous contacter.</p>\n<ul>\n<li>Email : {{email}}</li>\n<li>Téléphone : {{phone}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"slug": "homepage",
|
||||
"title": "{{business_name}}",
|
||||
"template": "full",
|
||||
"is_published": true,
|
||||
"show_in_header": false,
|
||||
"sections": {
|
||||
"hero": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
|
||||
"subtitle": {"translations": {"en": "{{meta_description}}", "fr": "{{meta_description}}"}},
|
||||
"background_type": "gradient",
|
||||
"buttons": [
|
||||
{"label": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "primary"}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "What We Offer", "fr": "Nos Services"}},
|
||||
"items": [
|
||||
{"icon": "shield-check", "title": {"translations": {"en": "Quality", "fr": "Qualité"}}, "description": {"translations": {"en": "Committed to excellence in everything we do", "fr": "Engagés pour l'excellence dans tout ce que nous faisons"}}},
|
||||
{"icon": "clock", "title": {"translations": {"en": "Reliability", "fr": "Fiabilité"}}, "description": {"translations": {"en": "Dependable service you can count on", "fr": "Un service fiable sur lequel vous pouvez compter"}}},
|
||||
{"icon": "users", "title": {"translations": {"en": "Experience", "fr": "Expérience"}}, "description": {"translations": {"en": "Years of expertise at your service", "fr": "Des années d'expertise à votre service"}}}
|
||||
]
|
||||
},
|
||||
"cta": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Ready to get started?", "fr": "Prêt à commencer ?"}},
|
||||
"subtitle": {"translations": {"en": "Contact us today for a free consultation", "fr": "Contactez-nous pour une consultation gratuite"}},
|
||||
"buttons": [
|
||||
{"label": {"translations": {"en": "Get in Touch", "fr": "Nous Contacter"}}, "url": "/contact", "style": "primary"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
15
app/modules/hosting/templates_library/generic/theme.json
Normal file
15
app/modules/hosting/templates_library/generic/theme.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"theme_name": "modern",
|
||||
"colors": {
|
||||
"primary": "#3b82f6",
|
||||
"secondary": "#1e40af",
|
||||
"accent": "#f59e0b",
|
||||
"background": "#ffffff",
|
||||
"text": "#1e293b",
|
||||
"border": "#e2e8f0"
|
||||
},
|
||||
"font_family_heading": "Inter",
|
||||
"font_family_body": "Inter",
|
||||
"layout_style": "grid",
|
||||
"header_style": "fixed"
|
||||
}
|
||||
40
app/modules/hosting/templates_library/manifest.json
Normal file
40
app/modules/hosting/templates_library/manifest.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"templates": [
|
||||
{
|
||||
"id": "generic",
|
||||
"name": "Generic Business",
|
||||
"description": "Clean, minimal template that works for any business type",
|
||||
"tags": ["general", "minimal", "any"],
|
||||
"pages": ["homepage", "about", "contact"]
|
||||
},
|
||||
{
|
||||
"id": "restaurant",
|
||||
"name": "Restaurant & Dining",
|
||||
"description": "Elegant template for restaurants, cafés, bars, and catering",
|
||||
"tags": ["food", "dining", "hospitality", "café"],
|
||||
"pages": ["homepage", "about", "menu", "contact"]
|
||||
},
|
||||
{
|
||||
"id": "construction",
|
||||
"name": "Construction & Renovation",
|
||||
"description": "Professional template for builders, renovators, and tradespeople",
|
||||
"tags": ["construction", "renovation", "building", "trades"],
|
||||
"pages": ["homepage", "services", "projects", "contact"]
|
||||
},
|
||||
{
|
||||
"id": "auto-parts",
|
||||
"name": "Auto Parts & Garage",
|
||||
"description": "Template for auto parts shops, garages, and car dealers",
|
||||
"tags": ["automotive", "garage", "car", "parts"],
|
||||
"pages": ["homepage", "catalog", "contact"]
|
||||
},
|
||||
{
|
||||
"id": "professional-services",
|
||||
"name": "Professional Services",
|
||||
"description": "Template for lawyers, accountants, consultants, and agencies",
|
||||
"tags": ["professional", "consulting", "legal", "finance"],
|
||||
"pages": ["homepage", "services", "team", "contact"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"id": "professional-services", "name": "Professional Services", "description": "Template for lawyers, accountants, consultants, and agencies", "tags": ["professional", "consulting", "legal", "finance"], "languages": ["en", "fr", "de"]}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"slug": "contact",
|
||||
"title": "Contact",
|
||||
"title_translations": {"en": "Contact Us", "fr": "Contactez-nous", "de": "Kontakt"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"show_in_footer": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Contact Us</h2>\n<p>Schedule a consultation or reach out with any questions.</p>\n<ul>\n<li>Phone: {{phone}}</li>\n<li>Email: {{email}}</li>\n<li>Address: {{address}}</li>\n</ul>",
|
||||
"fr": "<h2>Contactez-nous</h2>\n<p>Planifiez une consultation ou posez-nous vos questions.</p>\n<ul>\n<li>Téléphone : {{phone}}</li>\n<li>Email : {{email}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"slug": "homepage",
|
||||
"title": "{{business_name}}",
|
||||
"template": "full",
|
||||
"is_published": true,
|
||||
"sections": {
|
||||
"hero": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
|
||||
"subtitle": {"translations": {"en": "Professional expertise you can trust", "fr": "Une expertise professionnelle de confiance"}},
|
||||
"background_type": "gradient",
|
||||
"buttons": [
|
||||
{"label": {"translations": {"en": "Book a Consultation", "fr": "Prendre rendez-vous"}}, "url": "/contact", "style": "primary"},
|
||||
{"label": {"translations": {"en": "Our Expertise", "fr": "Notre expertise"}}, "url": "/services", "style": "secondary"}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Areas of Expertise", "fr": "Domaines d'expertise"}},
|
||||
"items": [
|
||||
{"icon": "briefcase", "title": {"translations": {"en": "Advisory", "fr": "Conseil"}}, "description": {"translations": {"en": "Strategic guidance tailored to your needs", "fr": "Conseils stratégiques adaptés à vos besoins"}}},
|
||||
{"icon": "document-text", "title": {"translations": {"en": "Compliance", "fr": "Conformité"}}, "description": {"translations": {"en": "Ensure regulatory compliance across your operations", "fr": "Assurez la conformité réglementaire de vos opérations"}}},
|
||||
{"icon": "chart-bar", "title": {"translations": {"en": "Analysis", "fr": "Analyse"}}, "description": {"translations": {"en": "Data-driven insights for informed decisions", "fr": "Analyses basées sur les données pour des décisions éclairées"}}}
|
||||
]
|
||||
},
|
||||
"cta": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Need professional guidance?", "fr": "Besoin d'un accompagnement professionnel ?"}},
|
||||
"buttons": [
|
||||
{"label": {"translations": {"en": "Schedule a Meeting", "fr": "Planifier un rendez-vous"}}, "url": "/contact", "style": "primary"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"slug": "services",
|
||||
"title": "Services",
|
||||
"title_translations": {"en": "Our Services", "fr": "Nos Services", "de": "Unsere Leistungen"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Our Services</h2>\n<p>We provide comprehensive professional services to help your business thrive.</p>",
|
||||
"fr": "<h2>Nos Services</h2>\n<p>Nous proposons des services professionnels complets pour aider votre entreprise à prospérer.</p>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"slug": "team",
|
||||
"title": "Team",
|
||||
"title_translations": {"en": "Our Team", "fr": "Notre Équipe", "de": "Unser Team"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Our Team</h2>\n<p>Meet the professionals behind {{business_name}}.</p>",
|
||||
"fr": "<h2>Notre Équipe</h2>\n<p>Découvrez les professionnels derrière {{business_name}}.</p>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"theme_name": "modern",
|
||||
"colors": {"primary": "#1e40af", "secondary": "#1e3a8a", "accent": "#3b82f6", "background": "#f8fafc", "text": "#0f172a", "border": "#cbd5e1"},
|
||||
"font_family_heading": "Merriweather",
|
||||
"font_family_body": "Source Sans Pro",
|
||||
"layout_style": "grid",
|
||||
"header_style": "fixed"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"id": "restaurant", "name": "Restaurant & Dining", "description": "Elegant template for restaurants, cafés, bars, and catering", "tags": ["food", "dining", "hospitality"], "languages": ["en", "fr", "de"]}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"slug": "about",
|
||||
"title": "About",
|
||||
"title_translations": {"en": "Our Story", "fr": "Notre Histoire", "de": "Über uns"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Our Story</h2>\n<p>{{about_paragraph}}</p>",
|
||||
"fr": "<h2>Notre Histoire</h2>\n<p>{{about_paragraph}}</p>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"slug": "contact",
|
||||
"title": "Contact",
|
||||
"title_translations": {"en": "Visit Us", "fr": "Nous Rendre Visite", "de": "Besuchen Sie uns"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"show_in_footer": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Visit Us</h2>\n<p>We look forward to welcoming you.</p>\n<ul>\n<li>Address: {{address}}</li>\n<li>Phone: {{phone}}</li>\n<li>Email: {{email}}</li>\n</ul>",
|
||||
"fr": "<h2>Nous Rendre Visite</h2>\n<p>Nous avons hâte de vous accueillir.</p>\n<ul>\n<li>Adresse : {{address}}</li>\n<li>Téléphone : {{phone}}</li>\n<li>Email : {{email}}</li>\n</ul>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"slug": "homepage",
|
||||
"title": "{{business_name}}",
|
||||
"template": "full",
|
||||
"is_published": true,
|
||||
"sections": {
|
||||
"hero": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
|
||||
"subtitle": {"translations": {"en": "A culinary experience in {{city}}", "fr": "Une expérience culinaire à {{city}}"}},
|
||||
"background_type": "image",
|
||||
"buttons": [
|
||||
{"label": {"translations": {"en": "Reserve a Table", "fr": "Réserver une table"}}, "url": "/contact", "style": "primary"},
|
||||
{"label": {"translations": {"en": "See Our Menu", "fr": "Voir la carte"}}, "url": "/menu", "style": "secondary"}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Our Specialties", "fr": "Nos Spécialités"}},
|
||||
"items": [
|
||||
{"icon": "fire", "title": {"translations": {"en": "Fresh Ingredients", "fr": "Produits frais"}}, "description": {"translations": {"en": "Locally sourced, seasonal ingredients", "fr": "Produits locaux et de saison"}}},
|
||||
{"icon": "star", "title": {"translations": {"en": "Chef's Selection", "fr": "Sélection du chef"}}, "description": {"translations": {"en": "Carefully crafted dishes by our expert chef", "fr": "Plats élaborés par notre chef expert"}}},
|
||||
{"icon": "heart", "title": {"translations": {"en": "Warm Atmosphere", "fr": "Ambiance chaleureuse"}}, "description": {"translations": {"en": "A welcoming space for every occasion", "fr": "Un espace accueillant pour chaque occasion"}}}
|
||||
]
|
||||
},
|
||||
"testimonials": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "What Our Guests Say", "fr": "Ce que disent nos clients"}},
|
||||
"items": []
|
||||
},
|
||||
"cta": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Ready to dine with us?", "fr": "Prêt à nous rendre visite ?"}},
|
||||
"buttons": [
|
||||
{"label": {"translations": {"en": "Make a Reservation", "fr": "Réserver"}}, "url": "/contact", "style": "primary"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"slug": "menu",
|
||||
"title": "Menu",
|
||||
"title_translations": {"en": "Our Menu", "fr": "Notre Carte", "de": "Speisekarte"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Our Menu</h2>\n<p>Discover our selection of dishes, prepared with fresh, local ingredients.</p>",
|
||||
"fr": "<h2>Notre Carte</h2>\n<p>Découvrez notre sélection de plats, préparés avec des produits frais et locaux.</p>"
|
||||
}
|
||||
}
|
||||
15
app/modules/hosting/templates_library/restaurant/theme.json
Normal file
15
app/modules/hosting/templates_library/restaurant/theme.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"theme_name": "modern",
|
||||
"colors": {
|
||||
"primary": "#b45309",
|
||||
"secondary": "#78350f",
|
||||
"accent": "#f59e0b",
|
||||
"background": "#fffbeb",
|
||||
"text": "#1c1917",
|
||||
"border": "#e7e5e4"
|
||||
},
|
||||
"font_family_heading": "Playfair Display",
|
||||
"font_family_body": "Inter",
|
||||
"layout_style": "grid",
|
||||
"header_style": "transparent"
|
||||
}
|
||||
@@ -89,6 +89,7 @@ def hosted_site(db, hosting_platform, system_merchant):
|
||||
db,
|
||||
{
|
||||
"business_name": f"Test Business {unique}",
|
||||
"merchant_id": system_merchant.id,
|
||||
"contact_name": "John Doe",
|
||||
"contact_email": f"john-{unique}@example.com",
|
||||
"contact_phone": "+352 123 456",
|
||||
|
||||
@@ -55,6 +55,7 @@ class TestHostedSiteService:
|
||||
db,
|
||||
{
|
||||
"business_name": f"New Business {unique}",
|
||||
"merchant_id": system_merchant.id,
|
||||
"contact_email": f"test-{unique}@example.com",
|
||||
},
|
||||
)
|
||||
@@ -72,6 +73,7 @@ class TestHostedSiteService:
|
||||
db,
|
||||
{
|
||||
"business_name": f"Full Business {unique}",
|
||||
"merchant_id": system_merchant.id,
|
||||
"contact_name": "Jane Doe",
|
||||
"contact_email": f"jane-{unique}@example.com",
|
||||
"contact_phone": "+352 999 888",
|
||||
@@ -251,75 +253,65 @@ class TestHostedSiteLifecycle:
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.hosting
|
||||
class TestHostedSiteFromProspect:
|
||||
"""Tests for creating hosted sites from prospects."""
|
||||
"""Tests for creating hosted sites from prospects via prospect_id."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = HostedSiteService()
|
||||
|
||||
def test_create_from_prospect(self, db, hosting_platform, system_merchant):
|
||||
"""Test creating a hosted site from a prospect."""
|
||||
from app.modules.prospecting.models import Prospect
|
||||
def test_create_with_prospect_id(self, db, hosting_platform):
|
||||
"""Test creating a hosted site with prospect_id auto-creates merchant."""
|
||||
from app.modules.prospecting.models import Prospect, ProspectContact
|
||||
|
||||
unique = uuid.uuid4().hex[:8]
|
||||
prospect = Prospect(
|
||||
channel="digital",
|
||||
domain_name=f"prospect-{uuid.uuid4().hex[:8]}.lu",
|
||||
domain_name=f"prospect-{unique}.lu",
|
||||
business_name="Prospect Business",
|
||||
status="active",
|
||||
has_website=True,
|
||||
)
|
||||
db.add(prospect)
|
||||
db.flush()
|
||||
|
||||
# Add email contact (needed for merchant creation)
|
||||
db.add(ProspectContact(
|
||||
prospect_id=prospect.id,
|
||||
contact_type="email",
|
||||
value=f"hello-{unique}@test.lu",
|
||||
is_primary=True,
|
||||
))
|
||||
db.commit()
|
||||
db.refresh(prospect)
|
||||
|
||||
site = self.service.create_from_prospect(db, prospect.id)
|
||||
site = self.service.create(
|
||||
db,
|
||||
{
|
||||
"business_name": "Prospect Business",
|
||||
"prospect_id": prospect.id,
|
||||
"contact_email": f"hello-{unique}@test.lu",
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert site.id is not None
|
||||
assert site.prospect_id == prospect.id
|
||||
assert site.business_name == "Prospect Business"
|
||||
assert site.store.merchant is not None
|
||||
|
||||
def test_create_from_prospect_with_contacts(
|
||||
self, db, hosting_platform, system_merchant
|
||||
):
|
||||
"""Test creating from prospect pre-fills contact info."""
|
||||
from app.modules.prospecting.models import Prospect, ProspectContact
|
||||
|
||||
prospect = Prospect(
|
||||
channel="digital",
|
||||
domain_name=f"contacts-{uuid.uuid4().hex[:8]}.lu",
|
||||
business_name="Contact Business",
|
||||
status="active",
|
||||
def test_create_without_merchant_or_prospect_raises(self, db, hosting_platform):
|
||||
"""Test that creating without merchant_id or prospect_id raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Either merchant_id or prospect_id"):
|
||||
self.service.create(
|
||||
db,
|
||||
{"business_name": "No Owner"},
|
||||
)
|
||||
db.add(prospect)
|
||||
db.flush()
|
||||
|
||||
email_contact = ProspectContact(
|
||||
prospect_id=prospect.id,
|
||||
contact_type="email",
|
||||
value="hello@test.lu",
|
||||
is_primary=True,
|
||||
)
|
||||
phone_contact = ProspectContact(
|
||||
prospect_id=prospect.id,
|
||||
contact_type="phone",
|
||||
value="+352 111 222",
|
||||
is_primary=True,
|
||||
)
|
||||
db.add_all([email_contact, phone_contact])
|
||||
db.commit()
|
||||
db.refresh(prospect)
|
||||
|
||||
site = self.service.create_from_prospect(db, prospect.id)
|
||||
db.commit()
|
||||
|
||||
assert site.contact_email == "hello@test.lu"
|
||||
assert site.contact_phone == "+352 111 222"
|
||||
|
||||
def test_create_from_nonexistent_prospect(
|
||||
self, db, hosting_platform, system_merchant
|
||||
):
|
||||
"""Test creating from non-existent prospect raises exception."""
|
||||
def test_create_with_nonexistent_prospect_raises(self, db, hosting_platform):
|
||||
"""Test creating with non-existent prospect raises exception."""
|
||||
from app.modules.prospecting.exceptions import ProspectNotFoundException
|
||||
|
||||
with pytest.raises(ProspectNotFoundException):
|
||||
self.service.create_from_prospect(db, 99999)
|
||||
self.service.create(
|
||||
db,
|
||||
{"business_name": "Ghost", "prospect_id": 99999},
|
||||
)
|
||||
|
||||
@@ -26,6 +26,9 @@ class ModuleConfig(BaseSettings):
|
||||
# Max concurrent HTTP requests for batch scanning
|
||||
max_concurrent_requests: int = 10
|
||||
|
||||
# Delay between prospects in batch scans (seconds) — be polite to target sites
|
||||
batch_delay_seconds: float = 1.0
|
||||
|
||||
model_config = {"env_prefix": "PROSPECTING_", "env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
|
||||
74
app/modules/prospecting/docs/batch-scanning.md
Normal file
74
app/modules/prospecting/docs/batch-scanning.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Batch Scanning & Rate Limiting
|
||||
|
||||
## Overview
|
||||
|
||||
The prospecting module performs passive scans against prospect websites to gather intelligence. Batch operations process multiple prospects sequentially with a configurable delay between each.
|
||||
|
||||
## Scan Types
|
||||
|
||||
| Type | What It Does | HTTP Requests/Prospect |
|
||||
|---|---|---|
|
||||
| **HTTP Check** | Connectivity, HTTPS, redirects | 2 (HTTP + HTTPS) |
|
||||
| **Tech Scan** | CMS, framework, server detection | 1 (homepage) |
|
||||
| **Performance** | PageSpeed Insights audit | 1 (Google API) |
|
||||
| **Contact Scrape** | Email, phone, address extraction | 6 (homepage + 5 subpages) |
|
||||
| **Security Audit** | Headers, SSL, exposed files, cookies | ~35 (homepage + 30 path checks) |
|
||||
| **Score Compute** | Calculate opportunity score | 0 (local computation) |
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
# .env
|
||||
PROSPECTING_BATCH_DELAY_SECONDS=1.0 # delay between prospects (default: 1s)
|
||||
PROSPECTING_HTTP_TIMEOUT=10 # per-request timeout (default: 10s)
|
||||
```
|
||||
|
||||
### Where Delays Apply
|
||||
|
||||
- **Batch API endpoints** (`POST /enrichment/*/batch`) — 1s delay between prospects
|
||||
- **Celery background tasks** (`scan_tasks.py`) — same 1s delay
|
||||
- **Full enrichment** (`POST /enrichment/full/{id}`) — no delay (single prospect)
|
||||
- **Score compute batch** — no delay (no outbound HTTP)
|
||||
|
||||
### Scaling to 70k+ URLs
|
||||
|
||||
For bulk imports (e.g., domain registrar list), use Celery tasks with limits:
|
||||
|
||||
| Scan Type | Time per prospect | 70k URLs | Recommended Batch |
|
||||
|---|---|---|---|
|
||||
| HTTP Check | ~2s (timeout + delay) | ~39 hours | 500/batch via Celery |
|
||||
| Tech Scan | ~2s | ~39 hours | 500/batch |
|
||||
| Contact Scrape | ~12s (6 pages + delay) | ~10 days | 100/batch |
|
||||
| Security Audit | ~40s (35 paths + delay) | ~32 days | 50/batch |
|
||||
|
||||
**Recommendation:** For 70k URLs, run HTTP Check first (fastest, filters out dead sites). Then run subsequent scans only on prospects with `has_website=True` (~50-70% of domains typically have working sites).
|
||||
|
||||
### Pipeline Order
|
||||
|
||||
```
|
||||
1. HTTP Check batch → sets has_website, filters dead domains
|
||||
2. Tech Scan batch → only where has_website=True
|
||||
3. Contact Scrape → only where has_website=True
|
||||
4. Security Audit → only where has_website=True
|
||||
5. Score Compute → all prospects (local, fast)
|
||||
```
|
||||
|
||||
Each scan type uses `last_*_at` timestamps to track what's been processed. Re-running a batch only processes prospects that haven't been scanned yet.
|
||||
|
||||
## User-Agent
|
||||
|
||||
All scans use a standard Chrome User-Agent:
|
||||
```
|
||||
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
|
||||
```
|
||||
|
||||
The security audit also identifies as `OrionBot/1.0` in the contact scraper for transparency.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Individual prospect failures don't stop the batch
|
||||
- Errors are logged but the next prospect continues
|
||||
- The scan job record tracks `processed_items` vs `total_items`
|
||||
- Celery tasks retry on failure (2 retries with exponential backoff)
|
||||
@@ -22,6 +22,7 @@ from app.modules.prospecting.models.prospect import (
|
||||
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.security_audit import ProspectSecurityAudit
|
||||
from app.modules.prospecting.models.tech_profile import ProspectTechProfile
|
||||
|
||||
__all__ = [
|
||||
@@ -44,4 +45,5 @@ __all__ = [
|
||||
"CampaignChannel",
|
||||
"CampaignSendStatus",
|
||||
"LeadType",
|
||||
"ProspectSecurityAudit",
|
||||
]
|
||||
|
||||
@@ -69,10 +69,16 @@ class Prospect(Base, TimestampMixin):
|
||||
last_tech_scan_at = Column(DateTime, nullable=True)
|
||||
last_perf_scan_at = Column(DateTime, nullable=True)
|
||||
last_contact_scrape_at = Column(DateTime, nullable=True)
|
||||
last_security_audit_at = Column(DateTime, nullable=True)
|
||||
last_content_scrape_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Scraped page content for POC builder
|
||||
scraped_content_json = Column(Text, 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")
|
||||
security_audit = relationship("ProspectSecurityAudit", 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")
|
||||
|
||||
@@ -20,6 +20,7 @@ class JobType(str, enum.Enum):
|
||||
SCORE_COMPUTE = "score_compute"
|
||||
FULL_ENRICHMENT = "full_enrichment"
|
||||
SECURITY_AUDIT = "security_audit"
|
||||
CONTENT_SCRAPE = "content_scrape"
|
||||
|
||||
|
||||
class JobStatus(str, enum.Enum):
|
||||
|
||||
59
app/modules/prospecting/models/security_audit.py
Normal file
59
app/modules/prospecting/models/security_audit.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# app/modules/prospecting/models/security_audit.py
|
||||
"""
|
||||
Security audit results for a prospect's website.
|
||||
|
||||
Stores findings from passive security checks (HTTPS, headers, exposed files,
|
||||
cookies, server info, technology detection). Follows the same 1:1 pattern as
|
||||
ProspectTechProfile and ProspectPerformanceProfile.
|
||||
"""
|
||||
|
||||
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 ProspectSecurityAudit(Base, TimestampMixin):
|
||||
"""Security audit results for a prospect's website."""
|
||||
|
||||
__tablename__ = "prospect_security_audits"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
prospect_id = Column(
|
||||
Integer,
|
||||
ForeignKey("prospects.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
|
||||
# Overall score and grade
|
||||
score = Column(Integer, nullable=False, default=0) # 0-100
|
||||
grade = Column(String(2), nullable=False, default="F") # A+, A, B, C, D, F
|
||||
|
||||
# Detected language for bilingual reports
|
||||
detected_language = Column(String(5), nullable=True, default="en")
|
||||
|
||||
# Findings stored as JSON (variable structure per check)
|
||||
findings_json = Column(Text, nullable=True) # JSON list of finding dicts
|
||||
|
||||
# Denormalized severity counts (for dashboard queries without JSON parsing)
|
||||
findings_count_critical = Column(Integer, nullable=False, default=0)
|
||||
findings_count_high = Column(Integer, nullable=False, default=0)
|
||||
findings_count_medium = Column(Integer, nullable=False, default=0)
|
||||
findings_count_low = Column(Integer, nullable=False, default=0)
|
||||
findings_count_info = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Key results (denormalized for quick access)
|
||||
has_https = Column(Boolean, nullable=True)
|
||||
has_valid_ssl = Column(Boolean, nullable=True)
|
||||
ssl_expires_at = Column(DateTime, nullable=True)
|
||||
missing_headers_json = Column(Text, nullable=True) # JSON list of header names
|
||||
exposed_files_json = Column(Text, nullable=True) # JSON list of exposed paths
|
||||
technologies_json = Column(Text, nullable=True) # JSON list of detected techs
|
||||
|
||||
# Scan metadata
|
||||
scan_error = Column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
prospect = relationship("Prospect", back_populates="security_audit")
|
||||
@@ -8,12 +8,15 @@ catch "batch" as a string before trying to parse it as int → 422.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
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.config import config as prospecting_config
|
||||
from app.modules.prospecting.models import JobType
|
||||
from app.modules.prospecting.schemas.enrichment import (
|
||||
ContactScrapeResponse,
|
||||
@@ -25,9 +28,18 @@ from app.modules.prospecting.schemas.enrichment import (
|
||||
ScanSingleResponse,
|
||||
ScoreComputeBatchResponse,
|
||||
)
|
||||
from app.modules.prospecting.schemas.security_audit import (
|
||||
SecurityAuditSingleResponse,
|
||||
)
|
||||
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.prospecting.services.security_audit_service import (
|
||||
security_audit_service,
|
||||
)
|
||||
from app.modules.prospecting.services.security_report_service import (
|
||||
security_report_service,
|
||||
)
|
||||
from app.modules.prospecting.services.stats_service import stats_service
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
@@ -35,6 +47,12 @@ router = APIRouter(prefix="/enrichment")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _batch_delay():
|
||||
"""Delay between prospects in batch scans to avoid rate limiting."""
|
||||
if prospecting_config.batch_delay_seconds > 0:
|
||||
time.sleep(prospecting_config.batch_delay_seconds)
|
||||
|
||||
|
||||
# ── Batch endpoints (must be before /{prospect_id} routes) ──────────────────
|
||||
|
||||
|
||||
@@ -48,9 +66,11 @@ def http_check_batch(
|
||||
job = stats_service.create_job(db,JobType.HTTP_CHECK)
|
||||
prospects = prospect_service.get_pending_http_check(db, limit=limit)
|
||||
results = []
|
||||
for prospect in prospects:
|
||||
for i, prospect in enumerate(prospects):
|
||||
result = enrichment_service.check_http(db, prospect)
|
||||
results.append(HttpCheckBatchItem(domain=prospect.domain_name, **result))
|
||||
if i < len(prospects) - 1:
|
||||
_batch_delay()
|
||||
stats_service.complete_job(job, processed=len(results))
|
||||
db.commit()
|
||||
return HttpCheckBatchResponse(processed=len(results), results=results)
|
||||
@@ -66,10 +86,12 @@ def tech_scan_batch(
|
||||
job = stats_service.create_job(db,JobType.TECH_SCAN)
|
||||
prospects = prospect_service.get_pending_tech_scan(db, limit=limit)
|
||||
count = 0
|
||||
for prospect in prospects:
|
||||
for i, prospect in enumerate(prospects):
|
||||
result = enrichment_service.scan_tech_stack(db, prospect)
|
||||
if result:
|
||||
count += 1
|
||||
if i < len(prospects) - 1:
|
||||
_batch_delay()
|
||||
stats_service.complete_job(job, processed=len(prospects))
|
||||
db.commit()
|
||||
return ScanBatchResponse(processed=len(prospects), successful=count)
|
||||
@@ -85,10 +107,12 @@ def performance_scan_batch(
|
||||
job = stats_service.create_job(db,JobType.PERFORMANCE_SCAN)
|
||||
prospects = prospect_service.get_pending_performance_scan(db, limit=limit)
|
||||
count = 0
|
||||
for prospect in prospects:
|
||||
for i, prospect in enumerate(prospects):
|
||||
result = enrichment_service.scan_performance(db, prospect)
|
||||
if result:
|
||||
count += 1
|
||||
if i < len(prospects) - 1:
|
||||
_batch_delay()
|
||||
stats_service.complete_job(job, processed=len(prospects))
|
||||
db.commit()
|
||||
return ScanBatchResponse(processed=len(prospects), successful=count)
|
||||
@@ -104,10 +128,54 @@ def contact_scrape_batch(
|
||||
job = stats_service.create_job(db,JobType.CONTACT_SCRAPE)
|
||||
prospects = prospect_service.get_pending_contact_scrape(db, limit=limit)
|
||||
count = 0
|
||||
for prospect in prospects:
|
||||
for i, prospect in enumerate(prospects):
|
||||
contacts = enrichment_service.scrape_contacts(db, prospect)
|
||||
if contacts:
|
||||
count += 1
|
||||
if i < len(prospects) - 1:
|
||||
_batch_delay()
|
||||
stats_service.complete_job(job, processed=len(prospects))
|
||||
db.commit()
|
||||
return ScanBatchResponse(processed=len(prospects), successful=count)
|
||||
|
||||
|
||||
@router.post("/content-scrape/batch", response_model=ScanBatchResponse)
|
||||
def content_scrape_batch(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Scrape page content for pending prospects."""
|
||||
job = stats_service.create_job(db, JobType.CONTENT_SCRAPE)
|
||||
prospects = prospect_service.get_pending_content_scrape(db, limit=limit)
|
||||
count = 0
|
||||
for i, prospect in enumerate(prospects):
|
||||
result = enrichment_service.scrape_content(db, prospect)
|
||||
if result:
|
||||
count += 1
|
||||
if i < len(prospects) - 1:
|
||||
_batch_delay()
|
||||
stats_service.complete_job(job, processed=len(prospects))
|
||||
db.commit()
|
||||
return ScanBatchResponse(processed=len(prospects), successful=count)
|
||||
|
||||
|
||||
@router.post("/security-audit/batch", response_model=ScanBatchResponse)
|
||||
def security_audit_batch(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Run security audit for pending prospects."""
|
||||
job = stats_service.create_job(db, JobType.SECURITY_AUDIT)
|
||||
prospects = prospect_service.get_pending_security_audit(db, limit=limit)
|
||||
count = 0
|
||||
for i, prospect in enumerate(prospects):
|
||||
result = security_audit_service.run_audit(db, prospect)
|
||||
if result:
|
||||
count += 1
|
||||
if i < len(prospects) - 1:
|
||||
_batch_delay()
|
||||
stats_service.complete_job(job, processed=len(prospects))
|
||||
db.commit()
|
||||
return ScanBatchResponse(processed=len(prospects), successful=count)
|
||||
@@ -127,6 +195,28 @@ def compute_scores_batch(
|
||||
return ScoreComputeBatchResponse(scored=count)
|
||||
|
||||
|
||||
# ── Report endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/security-audit/report/{prospect_id}", response_class=HTMLResponse)
|
||||
def security_audit_report(
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Generate branded HTML security audit report."""
|
||||
prospect = prospect_service.get_by_id(db, prospect_id)
|
||||
if not prospect.security_audit:
|
||||
from app.exceptions.base import ResourceNotFoundException
|
||||
|
||||
raise ResourceNotFoundException("SecurityAudit", str(prospect_id))
|
||||
html = security_report_service.generate_html_report(
|
||||
audit=prospect.security_audit,
|
||||
domain=prospect.domain_name,
|
||||
)
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
# ── Single-prospect endpoints ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -182,6 +272,40 @@ def scrape_contacts_single(
|
||||
return ContactScrapeResponse(domain=prospect.domain_name, contacts_found=len(contacts))
|
||||
|
||||
|
||||
@router.post("/security-audit/{prospect_id}", response_model=SecurityAuditSingleResponse)
|
||||
def security_audit_single(
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Run security audit for a single prospect."""
|
||||
prospect = prospect_service.get_by_id(db, prospect_id)
|
||||
audit = security_audit_service.run_audit(db, prospect)
|
||||
db.commit()
|
||||
findings_count = 0
|
||||
if audit:
|
||||
findings_count = audit.findings_count_critical + audit.findings_count_high + audit.findings_count_medium + audit.findings_count_low
|
||||
return SecurityAuditSingleResponse(
|
||||
domain=prospect.domain_name,
|
||||
score=audit.score if audit else 0,
|
||||
grade=audit.grade if audit else "F",
|
||||
findings_count=findings_count,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/content-scrape/{prospect_id}", response_model=ScanSingleResponse)
|
||||
def content_scrape_single(
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Scrape page content for a single prospect."""
|
||||
prospect = prospect_service.get_by_id(db, prospect_id)
|
||||
result = enrichment_service.scrape_content(db, prospect)
|
||||
db.commit()
|
||||
return ScanSingleResponse(domain=prospect.domain_name, profile=result is not None)
|
||||
|
||||
|
||||
@router.post("/full/{prospect_id}", response_model=FullEnrichmentResponse)
|
||||
def full_enrichment(
|
||||
prospect_id: int = Path(...),
|
||||
@@ -209,7 +333,15 @@ def full_enrichment(
|
||||
if prospect.has_website:
|
||||
contacts = enrichment_service.scrape_contacts(db, prospect)
|
||||
|
||||
# Step 5: Compute score
|
||||
# Step 5: Content scrape (if has website)
|
||||
if prospect.has_website:
|
||||
enrichment_service.scrape_content(db, prospect)
|
||||
|
||||
# Step 6: Security audit (if has website)
|
||||
if prospect.has_website:
|
||||
security_audit_service.run_audit(db, prospect)
|
||||
|
||||
# Step 7: Compute score
|
||||
db.refresh(prospect)
|
||||
score = scoring_service.compute_score(db, prospect)
|
||||
db.commit()
|
||||
|
||||
@@ -75,6 +75,7 @@ class ProspectDetailResponse(ProspectResponse):
|
||||
|
||||
tech_profile: "TechProfileResponse | None" = None
|
||||
performance_profile: "PerformanceProfileResponse | None" = None
|
||||
security_audit: "SecurityAuditResponse | None" = None
|
||||
contacts: list["ProspectContactResponse"] = []
|
||||
|
||||
class Config:
|
||||
@@ -114,6 +115,9 @@ 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.security_audit import (
|
||||
SecurityAuditResponse, # noqa: E402
|
||||
)
|
||||
from app.modules.prospecting.schemas.tech_profile import (
|
||||
TechProfileResponse, # noqa: E402
|
||||
)
|
||||
|
||||
82
app/modules/prospecting/schemas/security_audit.py
Normal file
82
app/modules/prospecting/schemas/security_audit.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# app/modules/prospecting/schemas/security_audit.py
|
||||
"""Pydantic schemas for security audit responses."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class SecurityAuditFinding(BaseModel):
|
||||
"""A single security finding."""
|
||||
|
||||
title: str
|
||||
severity: str
|
||||
category: str
|
||||
detail: str
|
||||
is_positive: bool = False
|
||||
|
||||
|
||||
class SecurityAuditResponse(BaseModel):
|
||||
"""Schema for security audit detail response."""
|
||||
|
||||
id: int
|
||||
prospect_id: int
|
||||
score: int
|
||||
grade: str
|
||||
detected_language: str | None = None
|
||||
findings: list[SecurityAuditFinding] = Field(default=[], validation_alias="findings_json")
|
||||
findings_count_critical: int = 0
|
||||
findings_count_high: int = 0
|
||||
findings_count_medium: int = 0
|
||||
findings_count_low: int = 0
|
||||
findings_count_info: int = 0
|
||||
has_https: bool | None = None
|
||||
has_valid_ssl: bool | None = None
|
||||
ssl_expires_at: datetime | None = None
|
||||
missing_headers: list[str] = Field(default=[], validation_alias="missing_headers_json")
|
||||
exposed_files: list[str] = Field(default=[], validation_alias="exposed_files_json")
|
||||
technologies: list[str] = Field(default=[], validation_alias="technologies_json")
|
||||
scan_error: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@field_validator("findings", mode="before")
|
||||
@classmethod
|
||||
def parse_findings(cls, v):
|
||||
if isinstance(v, str):
|
||||
return json.loads(v)
|
||||
return v
|
||||
|
||||
@field_validator("missing_headers", mode="before")
|
||||
@classmethod
|
||||
def parse_missing_headers(cls, v):
|
||||
if isinstance(v, str):
|
||||
return json.loads(v)
|
||||
return v or []
|
||||
|
||||
@field_validator("exposed_files", mode="before")
|
||||
@classmethod
|
||||
def parse_exposed_files(cls, v):
|
||||
if isinstance(v, str):
|
||||
return json.loads(v)
|
||||
return v or []
|
||||
|
||||
@field_validator("technologies", mode="before")
|
||||
@classmethod
|
||||
def parse_technologies(cls, v):
|
||||
if isinstance(v, str):
|
||||
return json.loads(v)
|
||||
return v or []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SecurityAuditSingleResponse(BaseModel):
|
||||
"""Response for single-prospect security audit."""
|
||||
|
||||
domain: str
|
||||
score: int
|
||||
grade: str
|
||||
findings_count: int
|
||||
@@ -16,6 +16,10 @@ import ssl
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
|
||||
# Suppress SSL warnings for intentional verify=False on prospect sites # noqa: SEC047
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # noqa: SEC047
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.prospecting.config import config
|
||||
@@ -210,6 +214,20 @@ class EnrichmentService:
|
||||
response = requests.get(api_url, params=params, timeout=60)
|
||||
data = response.json()
|
||||
|
||||
# Check for API-level errors (quota exceeded, invalid URL, etc.)
|
||||
if "error" in data:
|
||||
error_msg = data["error"].get("message", str(data["error"]))
|
||||
logger.warning("PageSpeed API error for %s: %s", domain, error_msg)
|
||||
profile = prospect.performance_profile
|
||||
if not profile:
|
||||
profile = ProspectPerformanceProfile(prospect_id=prospect.id)
|
||||
db.add(profile)
|
||||
profile.scan_error = error_msg
|
||||
profile.scan_strategy = "mobile"
|
||||
prospect.last_perf_scan_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
return profile
|
||||
|
||||
lighthouse = data.get("lighthouseResult", {})
|
||||
categories = lighthouse.get("categories", {})
|
||||
audits = lighthouse.get("audits", {})
|
||||
@@ -454,4 +472,159 @@ class EnrichmentService:
|
||||
return ",".join(found) if found else None
|
||||
|
||||
|
||||
def scrape_content(self, db: Session, prospect: Prospect) -> dict | None:
|
||||
"""Scrape page content (headings, paragraphs, images, services) for POC builder.
|
||||
|
||||
Uses BeautifulSoup to extract structured content from the prospect's
|
||||
website. Stores results as JSON in prospect.scraped_content_json.
|
||||
"""
|
||||
import json
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
domain = prospect.domain_name
|
||||
if not domain or not prospect.has_website:
|
||||
return None
|
||||
|
||||
scheme = "https" if prospect.uses_https else "http"
|
||||
base_url = f"{scheme}://{domain}"
|
||||
paths = ["", "/about", "/a-propos", "/services", "/nos-services", "/contact"]
|
||||
|
||||
session = requests.Session()
|
||||
session.verify = False # noqa: SEC047 passive scan
|
||||
session.headers.update({"User-Agent": "Mozilla/5.0 (compatible; OrionBot/1.0)"})
|
||||
|
||||
content = {
|
||||
"meta_description": None,
|
||||
"headings": [],
|
||||
"paragraphs": [],
|
||||
"services": [],
|
||||
"images": [],
|
||||
"social_links": {},
|
||||
"business_hours": None,
|
||||
"languages_detected": [],
|
||||
}
|
||||
|
||||
seen_headings = set()
|
||||
seen_paragraphs = set()
|
||||
|
||||
for path in paths:
|
||||
try:
|
||||
url = base_url + path
|
||||
resp = session.get(url, timeout=config.http_timeout, allow_redirects=True)
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
|
||||
# Meta description (first one found)
|
||||
if not content["meta_description"]:
|
||||
meta = soup.find("meta", attrs={"name": "description"})
|
||||
if meta and meta.get("content"):
|
||||
content["meta_description"] = meta["content"].strip()
|
||||
|
||||
# Language detection
|
||||
html_tag = soup.find("html")
|
||||
if html_tag and html_tag.get("lang"):
|
||||
lang = html_tag["lang"][:2].lower()
|
||||
if lang not in content["languages_detected"]:
|
||||
content["languages_detected"].append(lang)
|
||||
|
||||
# Headings (H1, H2)
|
||||
for tag in soup.find_all(["h1", "h2"]):
|
||||
text = tag.get_text(strip=True)
|
||||
if text and len(text) > 3 and text not in seen_headings:
|
||||
seen_headings.add(text)
|
||||
content["headings"].append(text)
|
||||
|
||||
# Paragraphs (substantial ones, skip tiny/boilerplate)
|
||||
for tag in soup.find_all("p"):
|
||||
text = tag.get_text(strip=True)
|
||||
if text and len(text) > 50 and text not in seen_paragraphs:
|
||||
seen_paragraphs.add(text)
|
||||
content["paragraphs"].append(text)
|
||||
if len(content["paragraphs"]) >= 20:
|
||||
break
|
||||
|
||||
# Images (hero/banner sized, skip tiny icons)
|
||||
for img in soup.find_all("img"):
|
||||
src = img.get("src") or img.get("data-src")
|
||||
if not src:
|
||||
continue
|
||||
# Make absolute
|
||||
if src.startswith("//"):
|
||||
src = "https:" + src
|
||||
elif src.startswith("/"):
|
||||
src = base_url + src
|
||||
elif not src.startswith("http"):
|
||||
continue
|
||||
# Skip tiny images, data URIs, tracking pixels
|
||||
if "1x1" in src or "pixel" in src or src.startswith("data:"):
|
||||
continue
|
||||
width = img.get("width", "")
|
||||
height = img.get("height", "")
|
||||
if width and width.isdigit() and int(width) < 100:
|
||||
continue
|
||||
if height and height.isdigit() and int(height) < 100:
|
||||
continue
|
||||
if src not in content["images"]:
|
||||
content["images"].append(src)
|
||||
if len(content["images"]) >= 15:
|
||||
break
|
||||
|
||||
# Social links
|
||||
for a in soup.find_all("a", href=True):
|
||||
href = a["href"]
|
||||
for platform, pattern in [
|
||||
("facebook", "facebook.com"),
|
||||
("instagram", "instagram.com"),
|
||||
("linkedin", "linkedin.com"),
|
||||
("twitter", "twitter.com"),
|
||||
("youtube", "youtube.com"),
|
||||
("tiktok", "tiktok.com"),
|
||||
]:
|
||||
if pattern in href and platform not in content["social_links"]:
|
||||
content["social_links"][platform] = href
|
||||
|
||||
# Service items (from list items near "service" headings)
|
||||
for heading in soup.find_all(["h2", "h3"]):
|
||||
heading_text = heading.get_text(strip=True).lower()
|
||||
if any(kw in heading_text for kw in ["service", "prestation", "leistung", "angebot", "nos activit"]):
|
||||
# Look for list items or cards after this heading
|
||||
sibling = heading.find_next_sibling()
|
||||
while sibling and sibling.name not in ["h1", "h2", "h3"]:
|
||||
if sibling.name in ["ul", "ol"]:
|
||||
for li in sibling.find_all("li"):
|
||||
text = li.get_text(strip=True)
|
||||
if text and len(text) > 3 and text not in content["services"]:
|
||||
content["services"].append(text)
|
||||
elif sibling.name == "div":
|
||||
# Cards pattern: divs with h3/h4 + p
|
||||
card_title = sibling.find(["h3", "h4", "h5"])
|
||||
if card_title:
|
||||
text = card_title.get_text(strip=True)
|
||||
if text and text not in content["services"]:
|
||||
content["services"].append(text)
|
||||
sibling = sibling.find_next_sibling()
|
||||
if len(content["services"]) >= 10:
|
||||
break
|
||||
|
||||
except Exception as e: # noqa: EXC003
|
||||
logger.debug("Content scrape failed for %s%s: %s", domain, path, e)
|
||||
|
||||
session.close()
|
||||
|
||||
# Store results
|
||||
prospect.scraped_content_json = json.dumps(content, ensure_ascii=False)
|
||||
prospect.last_content_scrape_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
"Content scrape for %s: %d headings, %d paragraphs, %d images, %d services",
|
||||
domain, len(content["headings"]), len(content["paragraphs"]),
|
||||
len(content["images"]), len(content["services"]),
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
enrichment_service = EnrichmentService()
|
||||
|
||||
@@ -251,6 +251,28 @@ class ProspectService:
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_pending_content_scrape(self, db: Session, limit: int = 100) -> list[Prospect]:
|
||||
return (
|
||||
db.query(Prospect)
|
||||
.filter(
|
||||
Prospect.has_website.is_(True),
|
||||
Prospect.last_content_scrape_at.is_(None),
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_pending_security_audit(self, db: Session, limit: int = 50) -> list[Prospect]:
|
||||
return (
|
||||
db.query(Prospect)
|
||||
.filter(
|
||||
Prospect.has_website.is_(True),
|
||||
Prospect.last_security_audit_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() # noqa: SVC-005 - prospecting is platform-scoped, not store-scoped
|
||||
return {status.value if hasattr(status, "value") else str(status): count for status, count in results}
|
||||
|
||||
75
app/modules/prospecting/services/security_audit_constants.py
Normal file
75
app/modules/prospecting/services/security_audit_constants.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# app/modules/prospecting/services/security_audit_constants.py
|
||||
"""
|
||||
Constants for security audit checks.
|
||||
|
||||
Structural data used by SecurityAuditService. Translations for report
|
||||
generation are kept in the standalone script (scripts/security-audit/audit.py)
|
||||
until Phase 2B (report service) migrates them.
|
||||
"""
|
||||
|
||||
# Severity scores — deducted from a starting score of 100
|
||||
SEVERITY_SCORES = {
|
||||
"critical": 15,
|
||||
"high": 10,
|
||||
"medium": 5,
|
||||
"low": 2,
|
||||
"info": 0,
|
||||
}
|
||||
|
||||
# Security headers to check and their severity if missing
|
||||
SECURITY_HEADERS = {
|
||||
"Strict-Transport-Security": {"severity": "high", "impact": "MITM attacks, session hijacking via HTTP downgrade"},
|
||||
"Content-Security-Policy": {"severity": "high", "impact": "XSS attacks, script injection, data theft"},
|
||||
"X-Frame-Options": {"severity": "medium", "impact": "Clickjacking attacks via invisible iframes"},
|
||||
"X-Content-Type-Options": {"severity": "medium", "impact": "MIME type confusion, content injection"},
|
||||
"Referrer-Policy": {"severity": "low", "impact": "URL parameter leakage to third parties"},
|
||||
"Permissions-Policy": {"severity": "low", "impact": "Unrestricted browser API access (camera, mic, location)"},
|
||||
"X-XSS-Protection": {"severity": "info", "impact": "Legacy XSS filter not configured"},
|
||||
}
|
||||
|
||||
# Paths to check for exposed sensitive files/directories
|
||||
EXPOSED_PATHS = [
|
||||
("/.env", "Environment file (database passwords, API keys)", "critical"),
|
||||
("/.git/config", "Git repository (full source code)", "critical"),
|
||||
("/.git/HEAD", "Git repository HEAD", "critical"),
|
||||
("/.htpasswd", "Password file", "critical"),
|
||||
("/wp-admin/", "WordPress admin panel", "high"),
|
||||
("/wp-login.php", "WordPress login page", "high"),
|
||||
("/administrator/", "Joomla admin panel", "high"),
|
||||
("/admin/", "Admin panel", "high"),
|
||||
("/admin/login", "Admin login page", "high"),
|
||||
("/phpmyadmin/", "phpMyAdmin (database manager)", "high"),
|
||||
("/backup/", "Backup directory", "high"),
|
||||
("/backup.zip", "Backup archive", "high"),
|
||||
("/backup.sql", "Database backup", "high"),
|
||||
("/db.sql", "Database dump", "high"),
|
||||
("/dump.sql", "Database dump", "high"),
|
||||
("/.htaccess", "Server configuration", "medium"),
|
||||
("/web.config", "IIS configuration", "medium"),
|
||||
("/server-status", "Apache server status", "medium"),
|
||||
("/server-info", "Apache server info", "medium"),
|
||||
("/info.php", "PHP info page", "medium"),
|
||||
("/phpinfo.php", "PHP info page", "medium"),
|
||||
("/graphql", "GraphQL endpoint", "medium"),
|
||||
("/debug/", "Debug endpoint", "medium"),
|
||||
("/elmah.axd", ".NET error log", "medium"),
|
||||
("/trace.axd", ".NET trace log", "medium"),
|
||||
("/readme.html", "CMS readme (reveals version)", "low"),
|
||||
("/license.txt", "CMS license (reveals version)", "low"),
|
||||
("/CHANGELOG.md", "Changelog (reveals version)", "low"),
|
||||
("/robots.txt", "Robots file", "info"),
|
||||
("/.well-known/security.txt", "Security contact file", "info"),
|
||||
("/sitemap.xml", "Sitemap", "info"),
|
||||
("/crossdomain.xml", "Flash cross-domain policy", "low"),
|
||||
("/api/", "API endpoint", "info"),
|
||||
]
|
||||
|
||||
# Paths that are admin panels (separate severity logic)
|
||||
ADMIN_PATHS = {"/wp-admin/", "/wp-login.php", "/administrator/", "/admin/", "/admin/login"}
|
||||
|
||||
# Robots.txt disallow patterns that may reveal sensitive areas
|
||||
ROBOTS_SENSITIVE_PATTERNS = [
|
||||
"admin", "backup", "private", "secret", "staging",
|
||||
"test", "dev", "internal", "api", "config",
|
||||
"database", "panel", "dashboard", "login", "cgi-bin",
|
||||
]
|
||||
443
app/modules/prospecting/services/security_audit_service.py
Normal file
443
app/modules/prospecting/services/security_audit_service.py
Normal file
@@ -0,0 +1,443 @@
|
||||
# app/modules/prospecting/services/security_audit_service.py
|
||||
"""
|
||||
Security audit service for prospect websites.
|
||||
|
||||
Performs passive security checks (HTTPS, SSL, headers, exposed files,
|
||||
cookies, server info, technology detection) and stores results as
|
||||
ProspectSecurityAudit. All checks are read-only — no active exploitation.
|
||||
|
||||
Migrated from scripts/security-audit/audit.py into the enrichment pipeline.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import requests
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.prospecting.models import Prospect, ProspectSecurityAudit
|
||||
from app.modules.prospecting.services.security_audit_constants import (
|
||||
ADMIN_PATHS,
|
||||
EXPOSED_PATHS,
|
||||
ROBOTS_SENSITIVE_PATTERNS,
|
||||
SECURITY_HEADERS,
|
||||
SEVERITY_SCORES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REQUEST_TIMEOUT = 10
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
class SecurityAuditService:
|
||||
"""Runs passive security checks against a prospect's website."""
|
||||
|
||||
def run_audit(self, db: Session, prospect: Prospect) -> ProspectSecurityAudit | None:
|
||||
"""Run all security checks and store results."""
|
||||
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}"
|
||||
findings = []
|
||||
technologies = []
|
||||
score = 100
|
||||
has_https = None
|
||||
has_valid_ssl = None
|
||||
ssl_expires_at = None
|
||||
missing_headers = []
|
||||
exposed_files = []
|
||||
|
||||
session = requests.Session()
|
||||
session.headers["User-Agent"] = USER_AGENT
|
||||
session.verify = True
|
||||
session.max_redirects = 5
|
||||
|
||||
# Fetch the page
|
||||
response = None
|
||||
html_content = ""
|
||||
try:
|
||||
response = session.get(url, timeout=REQUEST_TIMEOUT, allow_redirects=True)
|
||||
html_content = response.text
|
||||
if response.url != url:
|
||||
url = response.url
|
||||
except requests.exceptions.SSLError:
|
||||
findings.append(self._finding("Weak SSL/TLS configuration", "critical", "transport",
|
||||
"Server supports outdated encryption protocols"))
|
||||
try:
|
||||
session.verify = False # noqa: SEC047 fallback for broken SSL
|
||||
response = session.get(url, timeout=REQUEST_TIMEOUT, allow_redirects=True)
|
||||
html_content = response.text
|
||||
except Exception:
|
||||
pass
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning("Security audit: cannot reach %s: %s", domain, e)
|
||||
return self._save_audit(db, prospect, score=0, grade="F", findings=findings,
|
||||
scan_error=f"Cannot reach website: {e}",
|
||||
technologies=technologies)
|
||||
|
||||
# Run checks
|
||||
https_findings, has_https = self._check_https(url, html_content)
|
||||
findings.extend(https_findings)
|
||||
|
||||
ssl_findings, has_valid_ssl, ssl_expires_at = self._check_ssl(domain)
|
||||
findings.extend(ssl_findings)
|
||||
|
||||
header_findings, missing_headers = self._check_headers(response)
|
||||
findings.extend(header_findings)
|
||||
|
||||
server_findings, server_techs = self._check_server_info(response)
|
||||
findings.extend(server_findings)
|
||||
technologies.extend(server_techs)
|
||||
|
||||
tech_findings, detected_techs = self._check_technology(html_content, response)
|
||||
findings.extend(tech_findings)
|
||||
technologies.extend(detected_techs)
|
||||
|
||||
cookie_findings = self._check_cookies(response)
|
||||
findings.extend(cookie_findings)
|
||||
|
||||
exposed_findings, exposed_files = self._check_exposed_files(domain, scheme, session)
|
||||
findings.extend(exposed_findings)
|
||||
|
||||
session.close()
|
||||
|
||||
# Calculate score
|
||||
for f in findings:
|
||||
if not f.get("is_positive", False):
|
||||
score = max(0, score - SEVERITY_SCORES.get(f["severity"], 0))
|
||||
|
||||
grade = self._calculate_grade(score)
|
||||
|
||||
return self._save_audit(
|
||||
db, prospect,
|
||||
score=score, grade=grade, findings=findings,
|
||||
has_https=has_https, has_valid_ssl=has_valid_ssl,
|
||||
ssl_expires_at=ssl_expires_at,
|
||||
missing_headers=missing_headers, exposed_files=exposed_files,
|
||||
technologies=technologies,
|
||||
)
|
||||
|
||||
# ── Check methods ───────────────────────────────────────────────────────
|
||||
|
||||
def _check_https(self, url: str, html_content: str) -> tuple[list[dict], bool | None]:
|
||||
"""Check HTTPS configuration."""
|
||||
findings = []
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
has_https = parsed.scheme == "https"
|
||||
|
||||
if has_https:
|
||||
findings.append(self._finding("HTTPS enabled", "info", "transport",
|
||||
"Website uses encrypted connections", is_positive=True))
|
||||
# Check mixed content
|
||||
http_resources = re.findall(r'(src|href|action)=["\']http://[^"\']+["\']', html_content, re.IGNORECASE)
|
||||
if http_resources:
|
||||
findings.append(self._finding("Mixed content detected", "medium", "transport",
|
||||
"HTTPS site loads resources over insecure HTTP"))
|
||||
else:
|
||||
findings.append(self._finding("No HTTPS", "critical", "transport",
|
||||
"Website transmits all data in plain text"))
|
||||
|
||||
return findings, has_https
|
||||
|
||||
def _check_ssl(self, domain: str) -> tuple[list[dict], bool | None, datetime | None]:
|
||||
"""Check SSL certificate validity."""
|
||||
findings = []
|
||||
has_valid_ssl = None
|
||||
ssl_expires_at = None
|
||||
|
||||
try:
|
||||
context = ssl.create_default_context()
|
||||
with socket.create_connection((domain, 443), timeout=REQUEST_TIMEOUT) as sock:
|
||||
with context.wrap_socket(sock, server_hostname=domain) as ssock:
|
||||
cert = ssock.getpeercert()
|
||||
not_after = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(tzinfo=UTC)
|
||||
days_remaining = (not_after - datetime.now(UTC)).days
|
||||
ssl_expires_at = not_after
|
||||
|
||||
if days_remaining < 0:
|
||||
has_valid_ssl = False
|
||||
findings.append(self._finding("SSL certificate expired", "critical", "transport",
|
||||
f"Certificate expired on {not_after.strftime('%Y-%m-%d')}"))
|
||||
elif days_remaining < 30:
|
||||
has_valid_ssl = True
|
||||
findings.append(self._finding(f"SSL expires in {days_remaining} days", "high", "transport",
|
||||
f"Certificate expires on {not_after.strftime('%Y-%m-%d')}"))
|
||||
else:
|
||||
has_valid_ssl = True
|
||||
findings.append(self._finding("SSL certificate valid", "info", "transport",
|
||||
f"Valid until {not_after.strftime('%Y-%m-%d')} ({days_remaining} days)",
|
||||
is_positive=True))
|
||||
|
||||
# Check TLS version
|
||||
protocol = ssock.version()
|
||||
if protocol in ("TLSv1", "TLSv1.1", "SSLv3", "SSLv2"):
|
||||
findings.append(self._finding("Weak TLS version", "high", "transport",
|
||||
f"Server supports outdated protocol: {protocol}"))
|
||||
|
||||
except ssl.SSLCertVerificationError:
|
||||
has_valid_ssl = False
|
||||
findings.append(self._finding("SSL certificate invalid", "critical", "transport",
|
||||
"Certificate verification failed"))
|
||||
except (TimeoutError, ConnectionRefusedError, OSError):
|
||||
pass # No SSL, already caught by HTTPS check
|
||||
|
||||
return findings, has_valid_ssl, ssl_expires_at
|
||||
|
||||
def _check_headers(self, response) -> tuple[list[dict], list[str]]:
|
||||
"""Check for missing security headers."""
|
||||
findings = []
|
||||
missing = []
|
||||
|
||||
if not response:
|
||||
return findings, missing
|
||||
|
||||
for header_name, config in SECURITY_HEADERS.items():
|
||||
if header_name in response.headers:
|
||||
findings.append(self._finding(f"Header present: {header_name}", "info", "headers",
|
||||
header_name, is_positive=True))
|
||||
else:
|
||||
missing.append(header_name)
|
||||
findings.append(self._finding(f"Missing: {header_name}", config["severity"], "headers",
|
||||
config["impact"]))
|
||||
|
||||
return findings, missing
|
||||
|
||||
def _check_server_info(self, response) -> tuple[list[dict], list[str]]:
|
||||
"""Check for server version disclosure."""
|
||||
findings = []
|
||||
technologies = []
|
||||
|
||||
if not response:
|
||||
return findings, technologies
|
||||
|
||||
server = response.headers.get("Server", "")
|
||||
x_powered = response.headers.get("X-Powered-By", "")
|
||||
|
||||
info_parts = []
|
||||
if server:
|
||||
info_parts.append(server)
|
||||
technologies.append(server)
|
||||
if x_powered:
|
||||
info_parts.append(f"X-Powered-By: {x_powered}")
|
||||
technologies.append(x_powered)
|
||||
|
||||
if info_parts:
|
||||
has_version = bool(re.search(r"\d+\.\d+", " ".join(info_parts)))
|
||||
severity = "medium" if has_version else "low"
|
||||
findings.append(self._finding("Server version exposed", severity, "config",
|
||||
" | ".join(info_parts)))
|
||||
|
||||
return findings, technologies
|
||||
|
||||
def _check_technology(self, html_content: str, response) -> tuple[list[dict], list[str]]:
|
||||
"""Detect CMS and technology stack."""
|
||||
findings = []
|
||||
technologies = []
|
||||
|
||||
if not html_content:
|
||||
return findings, technologies
|
||||
|
||||
# WordPress
|
||||
wp_indicators = ["wp-content/", "wp-includes/", 'name="generator" content="WordPress']
|
||||
if any(ind in html_content for ind in wp_indicators):
|
||||
version = "unknown"
|
||||
ver_match = re.search(r'content="WordPress\s+([\d.]+)"', html_content)
|
||||
if ver_match:
|
||||
version = ver_match.group(1)
|
||||
severity = "medium" if version != "unknown" else "low"
|
||||
findings.append(self._finding(f"WordPress detected (v{version})", severity, "technology",
|
||||
"Version publicly visible" if version != "unknown" else "CMS detected"))
|
||||
technologies.append(f"WordPress {version}")
|
||||
|
||||
# Joomla
|
||||
if "/media/jui/" in html_content or "Joomla" in html_content:
|
||||
findings.append(self._finding("Joomla detected", "low", "technology", "CMS detected"))
|
||||
technologies.append("Joomla")
|
||||
|
||||
# Drupal
|
||||
if "Drupal" in html_content or "/sites/default/" in html_content:
|
||||
findings.append(self._finding("Drupal detected", "low", "technology", "CMS detected"))
|
||||
technologies.append("Drupal")
|
||||
|
||||
# Hosted platforms (not vulnerable in the same way)
|
||||
if "wix.com" in html_content:
|
||||
technologies.append("Wix")
|
||||
if "squarespace.com" in html_content:
|
||||
technologies.append("Squarespace")
|
||||
if "cdn.shopify.com" in html_content:
|
||||
technologies.append("Shopify")
|
||||
|
||||
return findings, technologies
|
||||
|
||||
def _check_cookies(self, response) -> list[dict]:
|
||||
"""Check cookie security flags."""
|
||||
findings = []
|
||||
|
||||
if not response:
|
||||
return findings
|
||||
|
||||
set_cookie_headers = response.headers.get("Set-Cookie", "")
|
||||
if not set_cookie_headers:
|
||||
return findings
|
||||
|
||||
has_insecure = False
|
||||
has_no_httponly = False
|
||||
has_no_samesite = False
|
||||
|
||||
for cookie in set_cookie_headers.split(","):
|
||||
cookie_lower = cookie.lower()
|
||||
if "secure" not in cookie_lower:
|
||||
has_insecure = True
|
||||
if "httponly" not in cookie_lower:
|
||||
has_no_httponly = True
|
||||
if "samesite" not in cookie_lower:
|
||||
has_no_samesite = True
|
||||
|
||||
if has_insecure:
|
||||
findings.append(self._finding("Cookies lack Secure flag", "medium", "cookies",
|
||||
"Session cookies can be intercepted over HTTP"))
|
||||
if has_no_httponly:
|
||||
findings.append(self._finding("Cookies lack HttpOnly flag", "medium", "cookies",
|
||||
"Cookies accessible to JavaScript (XSS risk)"))
|
||||
if has_no_samesite:
|
||||
findings.append(self._finding("Cookies lack SameSite attribute", "low", "cookies",
|
||||
"Vulnerable to cross-site request attacks"))
|
||||
|
||||
return findings
|
||||
|
||||
def _check_exposed_files(self, domain: str, scheme: str, session) -> tuple[list[dict], list[str]]:
|
||||
"""Check for exposed sensitive files and directories."""
|
||||
findings = []
|
||||
exposed = []
|
||||
base = f"{scheme}://{domain}"
|
||||
security_txt_found = False
|
||||
robots_content = None
|
||||
|
||||
for path, description, default_severity in EXPOSED_PATHS:
|
||||
try:
|
||||
resp = session.get(f"{base}{path}", timeout=REQUEST_TIMEOUT, allow_redirects=False)
|
||||
|
||||
if path == "/.well-known/security.txt" and resp.status_code == 200:
|
||||
security_txt_found = True
|
||||
continue
|
||||
if path == "/robots.txt" and resp.status_code == 200:
|
||||
robots_content = resp.text
|
||||
continue
|
||||
if path == "/sitemap.xml" or path == "/api/":
|
||||
continue
|
||||
|
||||
if resp.status_code == 200:
|
||||
if path in ADMIN_PATHS:
|
||||
findings.append(self._finding(f"Admin panel exposed: {path}", "high", "exposure",
|
||||
f"Admin login at {base}{path} is publicly accessible"))
|
||||
else:
|
||||
findings.append(self._finding(f"Exposed: {path}", default_severity, "exposure",
|
||||
f"{description} is publicly accessible"))
|
||||
exposed.append(path)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Security.txt check
|
||||
if not security_txt_found:
|
||||
findings.append(self._finding("No security.txt", "info", "exposure",
|
||||
"No /.well-known/security.txt for responsible disclosure"))
|
||||
|
||||
# Robots.txt analysis
|
||||
if robots_content:
|
||||
disallowed = re.findall(r"Disallow:\s*(.+)", robots_content, re.IGNORECASE)
|
||||
sensitive_found = []
|
||||
for path in disallowed:
|
||||
path = path.strip()
|
||||
if any(pattern in path.lower() for pattern in ROBOTS_SENSITIVE_PATTERNS):
|
||||
sensitive_found.append(path)
|
||||
|
||||
if sensitive_found:
|
||||
findings.append(self._finding("Robots.txt reveals sensitive paths", "low", "exposure",
|
||||
f"Disallowed paths: {', '.join(sensitive_found[:5])}"))
|
||||
|
||||
return findings, exposed
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _finding(title: str, severity: str, category: str, detail: str, is_positive: bool = False) -> dict:
|
||||
"""Create a finding dict."""
|
||||
return {
|
||||
"title": title,
|
||||
"severity": severity,
|
||||
"category": category,
|
||||
"detail": detail,
|
||||
"is_positive": is_positive,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _calculate_grade(score: int) -> str:
|
||||
if score >= 95:
|
||||
return "A+"
|
||||
if score >= 85:
|
||||
return "A"
|
||||
if score >= 70:
|
||||
return "B"
|
||||
if score >= 55:
|
||||
return "C"
|
||||
if score >= 40:
|
||||
return "D"
|
||||
return "F"
|
||||
|
||||
def _save_audit(
|
||||
self, db: Session, prospect: Prospect, *,
|
||||
score: int, grade: str, findings: list[dict],
|
||||
has_https: bool | None = None, has_valid_ssl: bool | None = None,
|
||||
ssl_expires_at: datetime | None = None,
|
||||
missing_headers: list[str] | None = None,
|
||||
exposed_files: list[str] | None = None,
|
||||
technologies: list[str] | None = None,
|
||||
scan_error: str | None = None,
|
||||
) -> ProspectSecurityAudit:
|
||||
"""Upsert security audit results."""
|
||||
audit = prospect.security_audit
|
||||
if not audit:
|
||||
audit = ProspectSecurityAudit(prospect_id=prospect.id)
|
||||
db.add(audit)
|
||||
|
||||
audit.score = score
|
||||
audit.grade = grade
|
||||
audit.findings_json = json.dumps(findings)
|
||||
audit.has_https = has_https
|
||||
audit.has_valid_ssl = has_valid_ssl
|
||||
audit.ssl_expires_at = ssl_expires_at
|
||||
audit.missing_headers_json = json.dumps(missing_headers or [])
|
||||
audit.exposed_files_json = json.dumps(exposed_files or [])
|
||||
audit.technologies_json = json.dumps(technologies or [])
|
||||
audit.scan_error = scan_error
|
||||
|
||||
# Denormalized counts
|
||||
audit.findings_count_critical = sum(1 for f in findings if f["severity"] == "critical" and not f.get("is_positive"))
|
||||
audit.findings_count_high = sum(1 for f in findings if f["severity"] == "high" and not f.get("is_positive"))
|
||||
audit.findings_count_medium = sum(1 for f in findings if f["severity"] == "medium" and not f.get("is_positive"))
|
||||
audit.findings_count_low = sum(1 for f in findings if f["severity"] == "low" and not f.get("is_positive"))
|
||||
audit.findings_count_info = sum(1 for f in findings if f["severity"] == "info" and not f.get("is_positive"))
|
||||
|
||||
prospect.last_security_audit_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
|
||||
logger.info("Security audit for %s: score=%d grade=%s (%d findings)",
|
||||
prospect.domain_name, score, grade,
|
||||
len([f for f in findings if not f.get("is_positive")]))
|
||||
return audit
|
||||
|
||||
|
||||
security_audit_service = SecurityAuditService()
|
||||
241
app/modules/prospecting/services/security_report_service.py
Normal file
241
app/modules/prospecting/services/security_report_service.py
Normal file
@@ -0,0 +1,241 @@
|
||||
# app/modules/prospecting/services/security_report_service.py
|
||||
"""
|
||||
Generate branded HTML security audit reports from stored audit data.
|
||||
|
||||
Produces a standalone HTML document suitable for viewing in a browser,
|
||||
printing to PDF, or emailing to prospects. Reports include:
|
||||
- Security grade and score
|
||||
- "What could happen" fear section with simulated hacked site
|
||||
- Detailed findings grouped by category
|
||||
- Business impact summary
|
||||
- Call to action with contact info
|
||||
"""
|
||||
|
||||
import html as html_module
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.modules.prospecting.models import ProspectSecurityAudit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEVERITY_COLORS = {
|
||||
"critical": ("#dc2626", "#fef2f2", "#991b1b"),
|
||||
"high": ("#ea580c", "#fff7ed", "#9a3412"),
|
||||
"medium": ("#ca8a04", "#fefce8", "#854d0e"),
|
||||
"low": ("#2563eb", "#eff6ff", "#1e40af"),
|
||||
"info": ("#6b7280", "#f9fafb", "#374151"),
|
||||
}
|
||||
|
||||
GRADE_COLORS = {
|
||||
"A+": "#16a34a", "A": "#22c55e", "B": "#eab308",
|
||||
"C": "#f97316", "D": "#ef4444", "F": "#991b1b",
|
||||
}
|
||||
|
||||
CATEGORY_LABELS = {
|
||||
"transport": "Transport Security (HTTPS/SSL)",
|
||||
"headers": "Security Headers",
|
||||
"exposure": "Information Exposure",
|
||||
"cookies": "Cookie Security",
|
||||
"config": "Server Configuration",
|
||||
"technology": "Technology & Versions",
|
||||
}
|
||||
|
||||
DEFAULT_CONTACT = {
|
||||
"name": "Samir Boulahtit",
|
||||
"email": "contact@wizard.lu",
|
||||
"phone": "+352 XXX XXX XXX",
|
||||
"company": "Wizard",
|
||||
"website": "https://wizard.lu",
|
||||
"tagline": "Professional Web Development & Security",
|
||||
}
|
||||
|
||||
|
||||
class SecurityReportService:
|
||||
"""Generate branded HTML security audit reports."""
|
||||
|
||||
def generate_html_report(
|
||||
self,
|
||||
audit: ProspectSecurityAudit,
|
||||
domain: str,
|
||||
contact: dict | None = None,
|
||||
) -> str:
|
||||
"""Generate a standalone HTML report from stored audit data."""
|
||||
contact = contact or DEFAULT_CONTACT
|
||||
esc = html_module.escape
|
||||
|
||||
findings = json.loads(audit.findings_json) if audit.findings_json else []
|
||||
technologies = json.loads(audit.technologies_json) if audit.technologies_json else []
|
||||
grade = audit.grade
|
||||
score = audit.score
|
||||
grade_color = GRADE_COLORS.get(grade, "#6b7280")
|
||||
|
||||
# Severity counts
|
||||
sev_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
||||
for f in findings:
|
||||
if not f.get("is_positive") and f["severity"] in sev_counts:
|
||||
sev_counts[f["severity"]] += 1
|
||||
|
||||
# Group findings by category
|
||||
categories = ["transport", "headers", "exposure", "cookies", "config", "technology"]
|
||||
grouped = {cat: [f for f in findings if f["category"] == cat] for cat in categories}
|
||||
|
||||
# Build findings HTML
|
||||
findings_html = ""
|
||||
for cat in categories:
|
||||
cat_findings = grouped.get(cat, [])
|
||||
if not cat_findings:
|
||||
continue
|
||||
label = CATEGORY_LABELS.get(cat, cat)
|
||||
findings_html += f'<h3 style="margin-top:32px;font-size:18px;color:#1e293b;border-bottom:2px solid #e2e8f0;padding-bottom:8px;">{esc(label)}</h3>\n'
|
||||
|
||||
for f in cat_findings:
|
||||
if f.get("is_positive"):
|
||||
findings_html += f"""
|
||||
<div style="margin:12px 0;padding:12px 16px;background:#f0fdf4;border-left:4px solid #16a34a;border-radius:0 8px 8px 0;">
|
||||
<span style="color:#16a34a;font-weight:600;">✓ {esc(f["title"])}</span>
|
||||
<span style="color:#166534;font-size:13px;margin-left:8px;">{esc(f["detail"])}</span>
|
||||
</div>"""
|
||||
else:
|
||||
sev_color, sev_bg, sev_text = SEVERITY_COLORS.get(f["severity"], SEVERITY_COLORS["info"])
|
||||
findings_html += f"""
|
||||
<div style="margin:16px 0;background:{sev_bg};border:1px solid {sev_color}20;border-radius:12px;overflow:hidden;">
|
||||
<div style="padding:16px 20px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
|
||||
<span style="background:{sev_color};color:white;padding:2px 10px;border-radius:20px;font-size:12px;font-weight:700;text-transform:uppercase;">{esc(f["severity"])}</span>
|
||||
<span style="font-weight:700;color:#1e293b;font-size:15px;">{esc(f["title"])}</span>
|
||||
</div>
|
||||
<div style="font-size:14px;color:#334155;margin-top:8px;">{esc(f["detail"])}</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
# Technologies
|
||||
tech_html = ""
|
||||
if technologies:
|
||||
tech_items = " ".join(
|
||||
f'<span style="background:#f1f5f9;padding:4px 12px;border-radius:20px;font-size:13px;color:#475569;border:1px solid #e2e8f0;">{esc(t)}</span>'
|
||||
for t in technologies
|
||||
)
|
||||
tech_html = f'<div style="margin-top:16px;"><span style="font-size:13px;color:#64748b;font-weight:600;">Technologies:</span><div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:8px;">{tech_items}</div></div>'
|
||||
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Security Audit Report — {esc(domain)}</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; color: #1e293b; line-height: 1.6; }}
|
||||
.container {{ max-width: 800px; margin: 0 auto; padding: 40px 24px; }}
|
||||
@media print {{ body {{ background: white; }} .container {{ padding: 20px; }} .no-print {{ display: none !important; }} .page-break {{ page-break-before: always; }} }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="text-align:center;margin-bottom:48px;">
|
||||
<div style="font-size:12px;letter-spacing:3px;color:#94a3b8;text-transform:uppercase;margin-bottom:8px;">🔒 Website Security Audit Report</div>
|
||||
<h1 style="font-size:28px;font-weight:800;color:#0f172a;margin-bottom:8px;">{esc(domain)}</h1>
|
||||
<div style="font-size:14px;color:#64748b;">Confidential — Prepared for {esc(domain)}</div>
|
||||
<div style="font-size:13px;color:#94a3b8;margin-top:4px;">Report generated on {now}</div>
|
||||
</div>
|
||||
|
||||
<!-- Score Card -->
|
||||
<div style="background:white;border-radius:16px;box-shadow:0 1px 3px rgba(0,0,0,0.1);padding:32px;margin-bottom:32px;text-align:center;">
|
||||
<h2 style="font-size:16px;color:#64748b;margin-bottom:24px;">Overall Security Grade</h2>
|
||||
<div style="display:inline-flex;align-items:center;justify-content:center;width:120px;height:120px;border-radius:50%;background:{grade_color};margin-bottom:16px;">
|
||||
<span style="font-size:48px;font-weight:900;color:white;">{grade}</span>
|
||||
</div>
|
||||
<div style="font-size:14px;color:#64748b;">Security Score: {score}/100</div>
|
||||
<div style="margin-top:16px;height:8px;background:#e2e8f0;border-radius:4px;overflow:hidden;">
|
||||
<div style="width:{score}%;height:100%;background:{grade_color};border-radius:4px;"></div>
|
||||
</div>
|
||||
<div style="margin-top:20px;display:flex;justify-content:center;gap:16px;flex-wrap:wrap;">
|
||||
<span style="font-size:13px;"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#dc2626;margin-right:4px;"></span>{sev_counts['critical']} Critical</span>
|
||||
<span style="font-size:13px;"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ea580c;margin-right:4px;"></span>{sev_counts['high']} High</span>
|
||||
<span style="font-size:13px;"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ca8a04;margin-right:4px;"></span>{sev_counts['medium']} Medium</span>
|
||||
<span style="font-size:13px;"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#2563eb;margin-right:4px;"></span>{sev_counts['low']} Low</span>
|
||||
</div>
|
||||
{tech_html}
|
||||
</div>
|
||||
|
||||
<!-- What Could Happen -->
|
||||
<div class="page-break" style="background:#1e293b;border-radius:16px;padding:32px;margin-bottom:32px;color:white;">
|
||||
<h2 style="font-size:20px;font-weight:700;color:#f87171;margin-bottom:24px;text-align:center;">⚠️ What Could Happen To Your Website</h2>
|
||||
<div style="background:#0f172a;border-radius:12px;overflow:hidden;border:1px solid #334155;margin-bottom:24px;">
|
||||
<div style="background:#1e293b;padding:8px 16px;display:flex;align-items:center;gap:8px;border-bottom:1px solid #334155;">
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:#ef4444;"></span>
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:#eab308;"></span>
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:#22c55e;"></span>
|
||||
<div style="flex:1;margin-left:8px;background:#0f172a;border-radius:6px;padding:4px 12px;">
|
||||
<span style="font-size:12px;color:#64748b;">🔒 https://{esc(domain)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:40px 24px;text-align:center;">
|
||||
<div style="font-size:64px;margin-bottom:16px;">💀</div>
|
||||
<div style="font-size:28px;font-weight:900;color:#ef4444;margin-bottom:16px;letter-spacing:2px;">WEBSITE COMPROMISED</div>
|
||||
<div style="font-size:14px;color:#94a3b8;max-width:500px;margin:0 auto;">This website has been hacked. All customer data including names, emails, phone numbers, and payment information has been stolen.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px;background:#dc262615;border-radius:8px;border:1px solid #dc262630;margin-bottom:24px;">
|
||||
<p style="font-size:13px;color:#fca5a5;">This is a simulation based on real-world attacks. With the vulnerabilities found on your site, this scenario is technically possible.</p>
|
||||
</div>
|
||||
<h3 style="font-size:16px;color:#f1f5f9;margin-bottom:16px;">Business Impact</h3>
|
||||
<ul style="list-style:none;padding:0;">
|
||||
<li style="margin-bottom:12px;padding-left:24px;position:relative;font-size:14px;color:#cbd5e1;"><span style="position:absolute;left:0;">❌</span> Reputation Damage — Customers lose trust</li>
|
||||
<li style="margin-bottom:12px;padding-left:24px;position:relative;font-size:14px;color:#cbd5e1;"><span style="position:absolute;left:0;">❌</span> GDPR Fines — Up to 4% of annual turnover or €20 million</li>
|
||||
<li style="margin-bottom:12px;padding-left:24px;position:relative;font-size:14px;color:#cbd5e1;"><span style="position:absolute;left:0;">❌</span> Google Blacklist — "This site may be hacked" warning kills traffic</li>
|
||||
<li style="margin-bottom:12px;padding-left:24px;position:relative;font-size:14px;color:#cbd5e1;"><span style="position:absolute;left:0;">❌</span> Business Downtime — Revenue loss during recovery</li>
|
||||
<li style="margin-bottom:12px;padding-left:24px;position:relative;font-size:14px;color:#cbd5e1;"><span style="position:absolute;left:0;">❌</span> Legal Liability — Liable for customers' stolen data</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Findings -->
|
||||
<div style="background:white;border-radius:16px;box-shadow:0 1px 3px rgba(0,0,0,0.1);padding:32px;margin-bottom:32px;">
|
||||
<h2 style="font-size:20px;font-weight:700;color:#0f172a;margin-bottom:24px;">Detailed Findings</h2>
|
||||
{findings_html}
|
||||
</div>
|
||||
|
||||
<!-- Call to Action -->
|
||||
<div style="background:linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);border-radius:16px;padding:40px;margin-bottom:32px;color:white;">
|
||||
<h2 style="font-size:24px;font-weight:800;margin-bottom:16px;text-align:center;">🛡️ Protect Your Business</h2>
|
||||
<p style="font-size:15px;color:#e9d5ff;text-align:center;margin-bottom:24px;max-width:600px;margin-left:auto;margin-right:auto;">
|
||||
Every day these vulnerabilities remain unfixed is another day your business and your customers are at risk.
|
||||
</p>
|
||||
<div style="background:rgba(255,255,255,0.1);border-radius:12px;padding:24px;margin-bottom:24px;">
|
||||
<h3 style="font-size:16px;margin-bottom:12px;">What We Offer</h3>
|
||||
<ul style="list-style:none;padding:0;">
|
||||
<li style="margin-bottom:8px;padding-left:24px;position:relative;font-size:14px;color:#e9d5ff;"><span style="position:absolute;left:0;">✔</span> Complete security audit and remediation plan</li>
|
||||
<li style="margin-bottom:8px;padding-left:24px;position:relative;font-size:14px;color:#e9d5ff;"><span style="position:absolute;left:0;">✔</span> Modern, secure website built with best practices</li>
|
||||
<li style="margin-bottom:8px;padding-left:24px;position:relative;font-size:14px;color:#e9d5ff;"><span style="position:absolute;left:0;">✔</span> Ongoing security monitoring and maintenance</li>
|
||||
<li style="margin-bottom:8px;padding-left:24px;position:relative;font-size:14px;color:#e9d5ff;"><span style="position:absolute;left:0;">✔</span> GDPR compliance and data protection</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="text-align:center;">
|
||||
<p style="font-size:14px;color:#c4b5fd;margin-bottom:16px;">Contact us today for a free consultation:</p>
|
||||
<div style="display:inline-block;background:white;border-radius:12px;padding:20px 32px;text-align:left;">
|
||||
<div style="font-size:18px;font-weight:700;color:#6d28d9;margin-bottom:8px;">{esc(contact['name'])}</div>
|
||||
<div style="font-size:14px;color:#475569;margin-bottom:4px;">📧 {esc(contact['email'])}</div>
|
||||
<div style="font-size:14px;color:#475569;margin-bottom:4px;">📞 {esc(contact['phone'])}</div>
|
||||
<div style="font-size:14px;color:#475569;">🌐 {esc(contact['website'])}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disclaimer -->
|
||||
<div style="text-align:center;padding:24px;font-size:12px;color:#94a3b8;line-height:1.7;">
|
||||
<p>This report was generated using passive, non-intrusive analysis techniques only. No active exploitation or unauthorized access was attempted.</p>
|
||||
<p style="margin-top:8px;">© {datetime.now().year} {esc(contact['company'])} — {esc(contact['tagline'])}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
security_report_service = SecurityReportService()
|
||||
@@ -13,10 +13,12 @@ function prospectDetail(prospectId) {
|
||||
campaignSends: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
auditRunning: false,
|
||||
|
||||
activeTab: 'overview',
|
||||
tabs: [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'security', label: 'Security' },
|
||||
{ id: 'interactions', label: 'Interactions' },
|
||||
{ id: 'campaigns', label: 'Campaigns' },
|
||||
],
|
||||
@@ -115,6 +117,43 @@ function prospectDetail(prospectId) {
|
||||
}
|
||||
},
|
||||
|
||||
openSecurityReport() {
|
||||
window.open('/api/v1/admin/prospecting/enrichment/security-audit/report/' + this.prospectId, '_blank');
|
||||
},
|
||||
|
||||
async runSecurityAudit() {
|
||||
this.auditRunning = true;
|
||||
try {
|
||||
await apiClient.post('/admin/prospecting/enrichment/security-audit/' + this.prospectId);
|
||||
Utils.showToast('Security audit complete', 'success');
|
||||
await this.loadProspect();
|
||||
} catch (err) {
|
||||
Utils.showToast('Audit failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
this.auditRunning = false;
|
||||
}
|
||||
},
|
||||
|
||||
gradeColor(grade) {
|
||||
if (!grade) return 'text-gray-400';
|
||||
if (grade === 'A+' || grade === 'A') return 'text-green-600 dark:text-green-400';
|
||||
if (grade === 'B') return 'text-blue-600 dark:text-blue-400';
|
||||
if (grade === 'C') return 'text-yellow-600 dark:text-yellow-400';
|
||||
if (grade === 'D') return 'text-orange-600 dark:text-orange-400';
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
},
|
||||
|
||||
severityBadge(severity) {
|
||||
var classes = {
|
||||
critical: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||
high: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||
medium: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
|
||||
low: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||
info: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
|
||||
};
|
||||
return classes[severity] || classes.info;
|
||||
},
|
||||
|
||||
scoreColor(score) {
|
||||
if (score == null) return 'text-gray-400';
|
||||
if (score >= 70) return 'text-red-600';
|
||||
|
||||
@@ -152,6 +152,18 @@ function prospectsList() {
|
||||
if (this.pagination.page > 1) { this.pagination.page--; this.loadProspects(); }
|
||||
},
|
||||
|
||||
async deleteProspect(prospect) {
|
||||
var name = prospect.business_name || prospect.domain_name || 'this prospect';
|
||||
if (!confirm('Delete "' + name + '"? This cannot be undone.')) return;
|
||||
try {
|
||||
await apiClient.delete('/admin/prospecting/prospects/' + prospect.id);
|
||||
Utils.showToast('Prospect deleted', 'success');
|
||||
await this.loadProspects();
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
statusBadgeClass(status) {
|
||||
const classes = {
|
||||
pending: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
|
||||
|
||||
@@ -53,6 +53,8 @@ function scanJobs() {
|
||||
'tech_scan': 'tech-scan',
|
||||
'performance_scan': 'performance',
|
||||
'contact_scrape': 'contacts',
|
||||
'content_scrape': 'content-scrape',
|
||||
'security_audit': 'security-audit',
|
||||
'score_compute': 'score-compute',
|
||||
},
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ Celery tasks for batch prospect scanning and enrichment.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.core.celery_config import celery_app
|
||||
from app.modules.prospecting.config import config as prospecting_config
|
||||
from app.modules.prospecting.models import ProspectScanJob
|
||||
from app.modules.task_base import ModuleTask
|
||||
|
||||
@@ -53,6 +55,8 @@ def batch_http_check(self, job_id: int, limit: int = 100):
|
||||
job.processed_items = processed
|
||||
if processed % 10 == 0:
|
||||
db.flush()
|
||||
if processed < len(prospects):
|
||||
time.sleep(prospecting_config.batch_delay_seconds)
|
||||
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.now(UTC)
|
||||
@@ -61,7 +65,7 @@ def batch_http_check(self, job_id: int, limit: int = 100):
|
||||
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.error_log = str(e)[:500]
|
||||
job.completed_at = datetime.now(UTC)
|
||||
db.commit() # SVC-006 - persist failure status
|
||||
raise
|
||||
@@ -110,6 +114,8 @@ def batch_tech_scan(self, job_id: int, limit: int = 100):
|
||||
job.processed_items = processed
|
||||
if processed % 10 == 0:
|
||||
db.flush()
|
||||
if processed < len(prospects):
|
||||
time.sleep(prospecting_config.batch_delay_seconds)
|
||||
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.now(UTC)
|
||||
@@ -118,7 +124,7 @@ def batch_tech_scan(self, job_id: int, limit: int = 100):
|
||||
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.error_log = str(e)[:500]
|
||||
job.completed_at = datetime.now(UTC)
|
||||
db.commit() # SVC-006 - persist failure status
|
||||
raise
|
||||
@@ -167,6 +173,8 @@ def batch_performance_scan(self, job_id: int, limit: int = 50):
|
||||
job.processed_items = processed
|
||||
if processed % 5 == 0:
|
||||
db.flush()
|
||||
if processed < len(prospects):
|
||||
time.sleep(prospecting_config.batch_delay_seconds)
|
||||
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.now(UTC)
|
||||
@@ -175,7 +183,7 @@ def batch_performance_scan(self, job_id: int, limit: int = 50):
|
||||
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.error_log = str(e)[:500]
|
||||
job.completed_at = datetime.now(UTC)
|
||||
db.commit() # SVC-006 - persist failure status
|
||||
raise
|
||||
@@ -223,6 +231,8 @@ def batch_contact_scrape(self, job_id: int, limit: int = 100):
|
||||
job.processed_items = processed
|
||||
if processed % 10 == 0:
|
||||
db.flush()
|
||||
if processed < len(prospects):
|
||||
time.sleep(prospecting_config.batch_delay_seconds)
|
||||
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.now(UTC)
|
||||
@@ -231,7 +241,7 @@ def batch_contact_scrape(self, job_id: int, limit: int = 100):
|
||||
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.error_log = str(e)[:500]
|
||||
job.completed_at = datetime.now(UTC)
|
||||
db.commit() # SVC-006 - persist failure status
|
||||
raise
|
||||
@@ -270,7 +280,7 @@ def batch_score_compute(self, job_id: int, limit: int = 500):
|
||||
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.error_log = str(e)[:500]
|
||||
job.completed_at = datetime.now(UTC)
|
||||
db.commit() # SVC-006 - persist failure status
|
||||
raise
|
||||
@@ -331,7 +341,7 @@ def full_enrichment(self, job_id: int, prospect_id: int):
|
||||
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.error_log = str(e)[:500]
|
||||
job.completed_at = datetime.now(UTC)
|
||||
db.commit() # SVC-006 - persist failure status
|
||||
raise
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
<!-- Tabs -->
|
||||
{{ tab_header([
|
||||
{'id': 'overview', 'label': 'Overview', 'icon': 'eye'},
|
||||
{'id': 'security', 'label': 'Security', 'icon': 'shield-check'},
|
||||
{'id': 'interactions', 'label': 'Interactions', 'icon': 'chat'},
|
||||
{'id': 'campaigns', 'label': 'Campaigns', 'icon': 'mail'},
|
||||
], active_var='activeTab') }}
|
||||
@@ -91,7 +92,8 @@
|
||||
|
||||
<!-- Score Breakdown -->
|
||||
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
{{ section_header('Score Breakdown', icon='chart-bar') }}
|
||||
{{ section_header('Opportunity Score', icon='chart-bar') }}
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mb-3">Higher = more issues = better sales opportunity</p>
|
||||
<template x-if="prospect.score">
|
||||
<div class="space-y-4">
|
||||
<template x-for="cat in scoreCategories()" :key="cat.key">
|
||||
@@ -120,7 +122,9 @@
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="cat.flags.length === 0" class="text-xs text-gray-400 ml-2">No issues detected</p>
|
||||
<p x-show="cat.flags.length === 0" class="text-xs ml-2"
|
||||
:class="cat.score === 0 ? 'text-green-500' : 'text-gray-400'"
|
||||
x-text="cat.score === 0 ? '✓ No issues found — low opportunity' : 'No data available'"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -162,26 +166,151 @@
|
||||
<!-- Performance Summary -->
|
||||
<div x-show="prospect.performance_profile" class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
{{ section_header('Performance', icon='chart-bar') }}
|
||||
<div class="space-y-2 text-sm">
|
||||
<!-- Scan error -->
|
||||
<div x-show="prospect.performance_profile?.scan_error" class="p-3 mb-3 text-sm text-orange-700 bg-orange-50 rounded-lg dark:bg-orange-900/20 dark:text-orange-300">
|
||||
<span class="font-medium">Scan failed:</span>
|
||||
<span x-text="prospect.performance_profile?.scan_error"></span>
|
||||
</div>
|
||||
<!-- Scores (only when real data exists) -->
|
||||
<div x-show="!prospect.performance_profile?.scan_error && (prospect.performance_profile?.performance_score > 0 || prospect.performance_profile?.seo_score > 0)" 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="text-gray-600 dark:text-gray-400">Performance</span>
|
||||
<span class="font-semibold" :class="scoreColor(prospect.performance_profile?.performance_score)"
|
||||
x-text="prospect.performance_profile?.performance_score ?? '—'"></span>
|
||||
x-text="prospect.performance_profile?.performance_score + '/100'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Accessibility</span>
|
||||
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="prospect.performance_profile?.accessibility_score + '/100'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Best Practices</span>
|
||||
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="prospect.performance_profile?.best_practices_score + '/100'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">SEO</span>
|
||||
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="prospect.performance_profile?.seo_score + '/100'"></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 dark:text-green-400' : 'text-red-600 dark:text-red-400'"
|
||||
<span x-text="prospect.performance_profile?.is_mobile_friendly == null ? '—' : prospect.performance_profile?.is_mobile_friendly ? 'Yes' : 'No'"
|
||||
:class="prospect.performance_profile?.is_mobile_friendly ? 'text-green-600 dark:text-green-400' : prospect.performance_profile?.is_mobile_friendly === false ? 'text-red-600 dark:text-red-400' : 'text-gray-400'"
|
||||
class="font-medium"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="!prospect.performance_profile?.scan_error && prospect.performance_profile?.performance_score === 0 && prospect.performance_profile?.seo_score === 0"
|
||||
class="text-sm text-gray-400 text-center py-4">No performance data — configure PAGESPEED_API_KEY in .env</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Security -->
|
||||
<div x-show="activeTab === 'security'" class="space-y-6">
|
||||
<!-- Action buttons -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" x-show="prospect.security_audit" @click="openSecurityReport()"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none">
|
||||
<span x-html="$icon('document-text', 'w-4 h-4 mr-2')"></span>
|
||||
Generate Report
|
||||
</button>
|
||||
<button type="button" @click="runSecurityAudit()" :disabled="auditRunning"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-yellow-600 border border-transparent rounded-lg hover:bg-yellow-700 focus:outline-none disabled:opacity-50">
|
||||
<span x-show="!auditRunning" x-html="$icon('shield-check', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="auditRunning" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="auditRunning ? 'Scanning...' : 'Run Security Audit'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template x-if="prospect.security_audit">
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Grade Card -->
|
||||
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800 text-center">
|
||||
<div class="text-5xl font-bold mb-2" :class="gradeColor(prospect.security_audit.grade)"
|
||||
x-text="prospect.security_audit.grade"></div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-3">Security Grade</div>
|
||||
<div class="text-2xl font-semibold text-gray-700 dark:text-gray-200"
|
||||
x-text="prospect.security_audit.score + '/100'"></div>
|
||||
<!-- Severity counts -->
|
||||
<div class="flex justify-center gap-3 mt-4">
|
||||
<span x-show="prospect.security_audit.findings_count_critical > 0"
|
||||
class="px-2 py-1 text-xs font-bold rounded bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300"
|
||||
x-text="prospect.security_audit.findings_count_critical + ' critical'"></span>
|
||||
<span x-show="prospect.security_audit.findings_count_high > 0"
|
||||
class="px-2 py-1 text-xs font-bold rounded bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300"
|
||||
x-text="prospect.security_audit.findings_count_high + ' high'"></span>
|
||||
<span x-show="prospect.security_audit.findings_count_medium > 0"
|
||||
class="px-2 py-1 text-xs font-bold rounded bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300"
|
||||
x-text="prospect.security_audit.findings_count_medium + ' medium'"></span>
|
||||
<span x-show="prospect.security_audit.findings_count_low > 0"
|
||||
class="px-2 py-1 text-xs font-bold rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
||||
x-text="prospect.security_audit.findings_count_low + ' low'"></span>
|
||||
</div>
|
||||
<p x-show="prospect.security_audit.scan_error" class="mt-3 text-xs text-red-500" x-text="prospect.security_audit.scan_error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Info -->
|
||||
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
{{ section_header('Quick Overview', icon='shield-check') }}
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">SEO Score</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="prospect.performance_profile?.seo_score ?? '—'"></span>
|
||||
<span class="text-gray-600 dark:text-gray-400">HTTPS</span>
|
||||
<span class="font-medium" :class="prospect.security_audit.has_https ? 'text-green-600' : 'text-red-600'"
|
||||
x-text="prospect.security_audit.has_https ? 'Yes' : 'No'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">SSL Valid</span>
|
||||
<span class="font-medium" :class="prospect.security_audit.has_valid_ssl ? 'text-green-600' : prospect.security_audit.has_valid_ssl === false ? 'text-red-600' : 'text-gray-400'"
|
||||
x-text="prospect.security_audit.has_valid_ssl == null ? '—' : prospect.security_audit.has_valid_ssl ? 'Yes' : 'No'"></span>
|
||||
</div>
|
||||
<div x-show="prospect.security_audit.ssl_expires_at" class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">SSL Expires</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300"
|
||||
x-text="new Date(prospect.security_audit.ssl_expires_at).toLocaleDateString()"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Missing Headers</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200"
|
||||
x-text="(prospect.security_audit.missing_headers || []).length"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Exposed Files</span>
|
||||
<span class="font-medium"
|
||||
:class="(prospect.security_audit.exposed_files || []).length > 0 ? 'text-red-600' : 'text-green-600'"
|
||||
x-text="(prospect.security_audit.exposed_files || []).length"></span>
|
||||
</div>
|
||||
<div x-show="(prospect.security_audit.technologies || []).length > 0" class="pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<span class="text-gray-600 dark:text-gray-400 text-xs uppercase">Technologies</span>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<template x-for="tech in prospect.security_audit.technologies || []" :key="tech">
|
||||
<span class="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded" x-text="tech"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Findings List (full width) -->
|
||||
<div class="md:col-span-2 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
{{ section_header('Findings', icon='clipboard-list') }}
|
||||
<div class="space-y-2">
|
||||
<template x-for="finding in (prospect.security_audit.findings || []).filter(f => !f.is_positive)" :key="finding.title">
|
||||
<div class="flex items-start gap-3 py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
|
||||
<span class="mt-0.5 px-2 py-0.5 text-xs font-bold rounded shrink-0"
|
||||
:class="severityBadge(finding.severity)"
|
||||
x-text="finding.severity"></span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="finding.title"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="finding.detail"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="(prospect.security_audit.findings || []).filter(f => !f.is_positive).length === 0"
|
||||
class="text-sm text-green-600 text-center py-4">No security issues found</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!prospect.security_audit" class="text-sm text-gray-400 text-center py-8">No security audit yet. Click "Run Security Audit" to scan.</p>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Interactions -->
|
||||
<div x-show="activeTab === 'interactions'" class="space-y-4">
|
||||
<div class="flex justify-end">
|
||||
|
||||
@@ -132,6 +132,11 @@
|
||||
title="View details">
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
<button type="button" @click="deleteProspect(p)"
|
||||
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||
title="Delete">
|
||||
<span x-html="$icon('trash', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -34,6 +34,16 @@
|
||||
<span x-html="$icon('mail', 'w-4 h-4 mr-2')"></span>
|
||||
Contact Scrape
|
||||
</button>
|
||||
<button type="button" @click="startBatchJob('content_scrape')"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none">
|
||||
<span x-html="$icon('document-text', 'w-4 h-4 mr-2')"></span>
|
||||
Content Scrape
|
||||
</button>
|
||||
<button type="button" @click="startBatchJob('security_audit')"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-yellow-600 border border-transparent rounded-lg hover:bg-yellow-700 focus:outline-none">
|
||||
<span x-html="$icon('shield-check', 'w-4 h-4 mr-2')"></span>
|
||||
Security Audit
|
||||
</button>
|
||||
<button type="button" @click="startBatchJob('score_compute')"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none">
|
||||
<span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span>
|
||||
|
||||
@@ -210,6 +210,18 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# Preview mode banner (POC site previews via signed URL) #}
|
||||
{% if request.state.is_preview|default(false) %}
|
||||
<div style="position:fixed;top:0;left:0;right:0;z-index:9999;background:linear-gradient(135deg,#0D9488,#14B8A6);color:white;padding:10px 20px;display:flex;align-items:center;justify-content:space-between;font-family:system-ui;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,0.15);">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<span style="font-weight:700;font-size:16px;">HostWizard</span>
|
||||
<span style="opacity:0.9;">Preview Mode</span>
|
||||
</div>
|
||||
<a href="https://hostwizard.lu" style="color:white;text-decoration:none;padding:6px 16px;border:1px solid rgba(255,255,255,0.4);border-radius:6px;font-size:13px;" target="_blank">hostwizard.lu</a>
|
||||
</div>
|
||||
<style>header { margin-top: 48px !important; }</style>
|
||||
{% endif %}
|
||||
|
||||
{# Mobile menu panel #}
|
||||
<div x-show="mobileMenuOpen"
|
||||
x-cloak
|
||||
|
||||
293
docs/proposals/end-to-end-prospecting-to-live-site.md
Normal file
293
docs/proposals/end-to-end-prospecting-to-live-site.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# End-to-End Plan: Prospecting → Live Site
|
||||
|
||||
## Context
|
||||
|
||||
Full pipeline from discovering a prospect to delivering a live hosted website. Three workstreams in dependency order, with AI content enhancement deferred to a later phase.
|
||||
|
||||
## Work Order
|
||||
|
||||
```
|
||||
Workstream 1: Fix Hosting Foundation ← FIRST (unblocks everything)
|
||||
Workstream 2: Security Audit Pipeline ← sales tool, enriches prospect data
|
||||
Workstream 3: POC Builder + Templates ← the wow factor (AI deferred)
|
||||
Workstream 4: AI Content Enhancement ← DEFERRED (provider TBD)
|
||||
```
|
||||
|
||||
## The Full Journey (target state)
|
||||
|
||||
```
|
||||
1. DISCOVER → Prospect created (digital scan or manual capture)
|
||||
2. ENRICH → HTTP check, tech scan, performance, contacts, security audit
|
||||
3. SCORE → Opportunity score + security grade → prioritize leads
|
||||
4. REPORT → Generate branded security report for sales meeting
|
||||
5. DEMO → Launch live hacker console to demonstrate vulnerabilities
|
||||
6. BUILD POC → Apply industry template, populate with scraped content
|
||||
7. PREVIEW → Client sees POC at acme.hostwizard.lu
|
||||
8. PROPOSE → Send proposal with security report + POC link
|
||||
9. ACCEPT → Create merchant, assign store, create subscription
|
||||
10. GO LIVE → Assign custom domain (acme.lu), DNS + SSL configured
|
||||
```
|
||||
|
||||
Steps 1-3 work today. Steps 4-10 need the work below.
|
||||
|
||||
---
|
||||
|
||||
## Workstream 1: Fix Hosting Foundation
|
||||
|
||||
**Goal:** Make site creation work, fix the merchant/prospect requirement, clean up the lifecycle.
|
||||
|
||||
**Proposal:** `docs/proposals/hosting-site-creation-fix.md`
|
||||
|
||||
### 1.1 Fix Site Creation Schema + Service
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `hosting/schemas/hosted_site.py` | Add `merchant_id: int | None`, `prospect_id: int | None`, `model_validator` requiring at least one |
|
||||
| `hosting/services/hosted_site_service.py` | Rewrite `create()`: if merchant_id → use it; if prospect_id → auto-create merchant from prospect. Remove system merchant hack. Remove `create_from_prospect()`. |
|
||||
| `hosting/routes/api/admin_sites.py` | Remove `POST /from-prospect/{prospect_id}` endpoint. Main `POST /sites` handles both paths. |
|
||||
|
||||
### 1.2 Fix Site Creation Template
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `hosting/templates/.../site-new.html` | Add merchant autocomplete dropdown (search existing merchants), prospect autocomplete dropdown (search existing prospects). Validate at least one selected. Auto-fill business_name/contacts from selection. |
|
||||
|
||||
### 1.3 Simplify accept_proposal
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `hosting/services/hosted_site_service.py` | `accept_proposal()` — merchant already exists at creation time, so remove merchant creation logic. Only needs: create subscription, mark prospect CONVERTED. |
|
||||
|
||||
### 1.4 Update Tests
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `hosting/tests/conftest.py` | Update `hosted_site` fixture to pass `merchant_id` |
|
||||
| `hosting/tests/unit/test_hosted_site_service.py` | Update create calls, add validation tests, remove `TestHostedSiteFromProspect` |
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
python -m pytest app/modules/hosting/tests/ -x -q
|
||||
# Manual: create site from /admin/hosting/sites/new with merchant + prospect
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workstream 2: Security Audit Pipeline
|
||||
|
||||
**Goal:** Add security scanning to enrichment pipeline + report generation + live demo.
|
||||
|
||||
**Proposal:** `docs/proposals/security-audit-demo-poc-builder.md`
|
||||
|
||||
### Phase 2A: Security Audit Service (scan + store results)
|
||||
|
||||
**Already done:** `ProspectSecurityAudit` model, `last_security_audit_at` column, relationship.
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `prospecting/services/security_audit_constants.py` | **CREATE** — Migrate TRANSLATIONS, EXPOSED_PATHS, SECURITY_HEADERS, SEVERITY_SCORES from `scripts/security-audit/audit.py` |
|
||||
| `prospecting/services/security_audit_service.py` | **CREATE** — `SecurityAuditService.run_audit(db, prospect)` with check_https, check_ssl, check_headers, check_exposed_files, check_cookies, check_server_info, check_technology |
|
||||
| `prospecting/schemas/security_audit.py` | **CREATE** — Response schemas |
|
||||
| `prospecting/migrations/versions/prospecting_002_security_audit.py` | **CREATE** — Table + column migration |
|
||||
| `prospecting/services/prospect_service.py` | Add `get_pending_security_audit()` |
|
||||
| `prospecting/routes/api/admin_enrichment.py` | Add `/security-audit/batch` + `/security-audit/{prospect_id}`. Add to `full_enrichment`. |
|
||||
| `prospecting/static/admin/js/scan-jobs.js` | Add `security_audit: 'security-audit'` to batchRoutes |
|
||||
| `prospecting/templates/.../scan-jobs.html` | Add "Security Audit" batch button |
|
||||
| `prospecting/templates/.../prospect-detail.html` | Add "Security" tab: grade badge, score bar, findings by severity, "Run Audit" button |
|
||||
| `prospecting/static/admin/js/prospect-detail.js` | Add `runSecurityAudit()`, tab handling |
|
||||
|
||||
### Phase 2B: Security Report Generation
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `prospecting/services/security_report_service.py` | **CREATE** — `generate_html_report(audit, prospect, language)` → standalone HTML. Migrate report template from `scripts/security-audit/audit.py` |
|
||||
| `prospecting/routes/api/admin_enrichment.py` | Add `GET /security-audit/{prospect_id}/report` → HTMLResponse |
|
||||
| `prospecting/templates/.../prospect-detail.html` | "Generate Report" button + language selector on security tab |
|
||||
|
||||
### Phase 2C: Live Demo Server
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `prospecting/models/demo_session.py` | **CREATE** — DemoSession model (prospect_id, status, port, pid) |
|
||||
| `prospecting/services/demo_service.py` | **CREATE** — start_demo (clone + spawn), stop_demo, stop_all. Migrate SiteCloner + DemoHandler from `scripts/security-audit/demo.py` |
|
||||
| `prospecting/services/demo_constants.py` | **CREATE** — HACKER_JS, HACKER_HTML_TEMPLATE, etc. |
|
||||
| `prospecting/routes/api/admin_demo.py` | **CREATE** — start/stop/list endpoints |
|
||||
| `prospecting/templates/.../prospect-detail.html` | "Launch Demo" / "Stop Demo" buttons on security tab |
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
python -m pytest app/modules/prospecting/tests/ -x -q
|
||||
# Manual: run security audit on prospect 1
|
||||
curl -X POST .../enrichment/security-audit/1
|
||||
# Manual: generate report
|
||||
curl .../enrichment/security-audit/1/report > report.html
|
||||
# Manual: launch demo
|
||||
curl -X POST .../demo/start/1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workstream 3: POC Builder + Industry Templates
|
||||
|
||||
**Goal:** One-click POC generation from prospect data using industry templates. Result is a near-final multi-page website.
|
||||
|
||||
**Architecture decision:** `docs/proposals/hosting-architecture-decision.md` — reuse CMS + Store + StoreDomain.
|
||||
|
||||
### Phase 3A: Content Scraping Enhancement
|
||||
|
||||
Before building POCs, we need more content from prospect sites than just contacts.
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `prospecting/models/prospect.py` | Add `scraped_content_json` column (Text) — stores extracted page content |
|
||||
| `prospecting/services/enrichment_service.py` | Add `scrape_content(db, prospect)` method: extract meta description, H1/H2 headings, first paragraphs, service/menu items, image URLs, business hours, social links using BeautifulSoup |
|
||||
| `prospecting/services/prospect_service.py` | Add `get_pending_content_scrape()` |
|
||||
| `prospecting/routes/api/admin_enrichment.py` | Add `/content-scrape/batch` + `/content-scrape/{prospect_id}` |
|
||||
| Add to `full_enrichment` pipeline | After contact scrape, before scoring |
|
||||
| Migration | Add `last_content_scrape_at`, `scraped_content_json` to prospects |
|
||||
|
||||
**Scraped content structure:**
|
||||
```json
|
||||
{
|
||||
"meta_description": "Bati Rénovation — Construction et rénovation à Strasbourg",
|
||||
"headings": ["Bati Rénovation", "Nos Services", "Nos Réalisations"],
|
||||
"paragraphs": ["Entreprise spécialisée dans la rénovation...", ...],
|
||||
"services": ["Rénovation intérieure", "Construction neuve", ...],
|
||||
"images": ["https://site.fr/hero.jpg", "https://site.fr/project1.jpg"],
|
||||
"social_links": {"facebook": "...", "instagram": "..."},
|
||||
"business_hours": "Lun-Ven 8h-18h",
|
||||
"languages_detected": ["fr"]
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3B: Template Infrastructure
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `hosting/templates_library/manifest.json` | **CREATE** — Registry of all templates |
|
||||
| `hosting/templates_library/generic/` | **CREATE** — Generic template (meta.json, pages/*.json, theme.json) |
|
||||
| `hosting/templates_library/restaurant/` | **CREATE** — Restaurant template |
|
||||
| `hosting/templates_library/construction/` | **CREATE** — Construction template |
|
||||
| `hosting/templates_library/auto-parts/` | **CREATE** — Auto parts template |
|
||||
| `hosting/templates_library/professional-services/` | **CREATE** — Professional services template |
|
||||
| `hosting/services/template_service.py` | **CREATE** — `list_templates()`, `get_template(id)`, `validate_template()` |
|
||||
| `hosting/schemas/template.py` | **CREATE** — `TemplateListResponse`, `TemplateDetailResponse` |
|
||||
| `hosting/routes/api/admin_sites.py` | Add `GET /templates` endpoint |
|
||||
|
||||
### Phase 3C: POC Builder Service
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `hosting/services/poc_builder_service.py` | **CREATE** — `build_poc(db, prospect_id, template_id, merchant_id)`: 1) Load template, 2) Load scraped content from prospect, 3) Create HostedSite + Store, 4) Populate ContentPages from template with prospect data replacing placeholders, 5) Apply StoreTheme from template, 6) Return HostedSite |
|
||||
| `hosting/routes/api/admin_sites.py` | Add `POST /poc/build` endpoint |
|
||||
| `hosting/templates/.../site-new.html` | Add template selector (cards with previews) to creation flow |
|
||||
|
||||
**Placeholder replacement:**
|
||||
```
|
||||
{{business_name}} → prospect.business_name or scraped H1
|
||||
{{city}} → prospect.city or extracted from address
|
||||
{{phone}} → primary phone contact
|
||||
{{email}} → primary email contact
|
||||
{{address}} → address contact value
|
||||
{{meta_description}} → scraped meta description
|
||||
{{services}} → scraped service items (for service grid sections)
|
||||
{{hero_image}} → first scraped image or template default
|
||||
```
|
||||
|
||||
### Phase 3D: CMS Section Types for Hosting
|
||||
|
||||
The existing CMS sections (hero, features, pricing, cta) cover basics. Hosting templates need a few more:
|
||||
|
||||
| Section Type | Used By | Description |
|
||||
|---|---|---|
|
||||
| `services_grid` | construction, professional | Service cards with icons and descriptions |
|
||||
| `gallery` | construction, restaurant | Image grid with lightbox |
|
||||
| `team_grid` | professional | Team member cards (photo, name, role) |
|
||||
| `menu_display` | restaurant | Menu categories with items and prices |
|
||||
| `testimonials` | all | Customer review cards |
|
||||
| `contact_form` | all | Configurable contact form (name, email, message) |
|
||||
| `map_embed` | all | Google Maps embed from address |
|
||||
| `hours_display` | restaurant, auto-parts | Business hours table |
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `cms/schemas/homepage_sections.py` | Add new section type schemas |
|
||||
| `app/templates/storefront/sections/` | **CREATE** — Jinja2 partials for each new section type |
|
||||
| `app/templates/storefront/content-page.html` | Update section renderer to handle new types |
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
python -m pytest app/modules/hosting/tests/ -x -q
|
||||
python -m pytest app/modules/prospecting/tests/ -x -q
|
||||
# Manual:
|
||||
# 1. Create prospect for batirenovation-strasbourg.fr
|
||||
# 2. Run full enrichment (includes content scrape)
|
||||
# 3. Go to /admin/hosting/sites/new → select prospect + "construction" template
|
||||
# 4. POC built → preview at subdomain
|
||||
# 5. Accept → go live with custom domain
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workstream 4: AI Content Enhancement (DEFERRED)
|
||||
|
||||
**Provider:** TBD (Claude API or OpenAI)
|
||||
**Scope:** TBD (full rewrite vs enhance + fill gaps)
|
||||
|
||||
When ready, this adds an AI step to the POC builder:
|
||||
1. After scraping content and before populating CMS pages
|
||||
2. AI enhances scraped text: better headlines, professional descriptions, SEO meta
|
||||
3. AI generates missing content: testimonials, about section, service descriptions
|
||||
4. AI translates to additional languages (fr/de/en/lb)
|
||||
5. Integrated as optional step — POC builder works without AI (just uses scraped text + template defaults)
|
||||
|
||||
| File | Action (future) |
|
||||
|---|---|
|
||||
| `app/core/config.py` or `hosting/config.py` | Add AI provider config (API key, model, temperature) |
|
||||
| `hosting/services/ai_content_service.py` | **CREATE** — `enhance_content(scraped, template, language)`, `generate_missing(template_sections, scraped)`, `translate(content, target_langs)` |
|
||||
| `hosting/services/poc_builder_service.py` | Call AI service between scrape and populate steps |
|
||||
| `.env.example` | Add `AI_PROVIDER`, `AI_API_KEY`, `AI_MODEL` |
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
Workstream 1 (hosting fix)
|
||||
├── 1.1 Schema + service ← no dependencies
|
||||
├── 1.2 Template form ← needs 1.1
|
||||
├── 1.3 Simplify accept ← needs 1.1
|
||||
└── 1.4 Tests ← needs 1.1-1.3
|
||||
|
||||
Workstream 2 (security audit)
|
||||
├── 2A Service + endpoints ← no dependencies (model done)
|
||||
├── 2B Report generation ← needs 2A
|
||||
└── 2C Live demo ← needs 2A conceptually
|
||||
|
||||
Workstream 3 (POC builder)
|
||||
├── 3A Content scraping ← no dependencies
|
||||
├── 3B Template files ← no dependencies
|
||||
├── 3C POC builder service ← needs 1.1 + 3A + 3B
|
||||
└── 3D CMS section types ← needs 3B (to know what sections templates use)
|
||||
|
||||
Workstream 4 (AI) ← DEFERRED
|
||||
└── needs 3C
|
||||
```
|
||||
|
||||
**Parallelizable:** 1.1 + 2A + 3A + 3B can all start simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Estimated Scope
|
||||
|
||||
| Workstream | New Files | Modified Files | ~Lines |
|
||||
|---|---|---|---|
|
||||
| 1 (hosting fix) | 0 | 5 | ~200 |
|
||||
| 2A (security service) | 4 | 6 | ~1200 |
|
||||
| 2B (report) | 1 | 2 | ~400 |
|
||||
| 2C (demo) | 4 | 3 | ~500 |
|
||||
| 3A (content scrape) | 0 | 4 | ~200 |
|
||||
| 3B (templates) | ~20 JSON | 2 | ~500 |
|
||||
| 3C (POC builder) | 2 | 3 | ~300 |
|
||||
| 3D (CMS sections) | ~8 | 2 | ~400 |
|
||||
| **Total** | **~40** | **~27** | **~3700** |
|
||||
236
docs/proposals/hosting-architecture-decision.md
Normal file
236
docs/proposals/hosting-architecture-decision.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Architecture Decision: Hosting Sites Leverage CMS + Store + StoreDomain
|
||||
|
||||
## Decision
|
||||
|
||||
Hosted sites (POC and live) reuse the existing CMS module, Store entity, and StoreDomain routing — **no separate site builder module**.
|
||||
|
||||
## Rationale
|
||||
|
||||
The CMS module already provides everything a hosted site needs:
|
||||
|
||||
- **ContentPage** — store-scoped pages with three-tier inheritance (platform defaults → store overrides)
|
||||
- **MediaFile** — store-scoped media library
|
||||
- **StoreTheme** — per-store colors, fonts, logo, layout, custom CSS
|
||||
- **Storefront rendering** — Jinja2 templates that resolve store context and render CMS content
|
||||
|
||||
Building a parallel system would duplicate all of this.
|
||||
|
||||
## How It Works
|
||||
|
||||
### POC Phase (DRAFT → POC_READY → PROPOSAL_SENT)
|
||||
|
||||
1. `HostedSite.create()` creates a Store on the `hosting` platform
|
||||
2. Store inherits **default CMS pages** from the hosting platform (homepage, about, contact, etc.)
|
||||
3. Admin selects an **industry template** (e.g., "restaurant", "construction") which applies:
|
||||
- Pre-built page sections (hero, services, gallery, testimonials, CTA)
|
||||
- Industry-appropriate theme (colors, fonts, layout)
|
||||
- Placeholder content that gets replaced with prospect data
|
||||
4. Admin customizes ContentPages + StoreTheme during POC phase
|
||||
5. POC preview accessible at subdomain: `acme.hostwizard.lu`
|
||||
|
||||
### Go Live (ACCEPTED → LIVE)
|
||||
|
||||
1. `go_live(domain)` calls `store_domain_service.add_domain()` — already implemented
|
||||
2. Custom domain `acme.lu` maps to the Store via `StoreDomain`
|
||||
3. `StoreContextMiddleware` resolves `acme.lu` → Store → CMS content
|
||||
4. Caddy + Cloudflare handle SSL and routing — already configured for wildcard subdomains
|
||||
|
||||
### Content Flow
|
||||
|
||||
```
|
||||
Prospect scanned (prospecting module)
|
||||
↓ business_name, contacts, address, tech_profile
|
||||
HostedSite created → Store created on hosting platform
|
||||
↓
|
||||
Industry template applied → ContentPages populated with prospect data
|
||||
↓ homepage hero: "Acme Construction — Strasbourg"
|
||||
↓ contact page: scraped email, phone, address
|
||||
↓ theme: construction industry colors + layout
|
||||
Admin refines content in CMS editor
|
||||
↓
|
||||
POC preview: acme.hostwizard.lu
|
||||
↓
|
||||
Client approves → go live: acme.lu
|
||||
```
|
||||
|
||||
## Industry Template System
|
||||
|
||||
### What a Template Is
|
||||
|
||||
A template is NOT a separate rendering engine. It is a **preset bundle** that populates CMS entities for a new Store:
|
||||
|
||||
- **ContentPages**: pre-built page slugs with section structures (hero, services, gallery, etc.)
|
||||
- **StoreTheme**: industry-appropriate colors, fonts, layout style
|
||||
- **Media placeholders**: stock images appropriate to the industry
|
||||
- **Section blueprints**: JSON structures for homepage sections
|
||||
|
||||
### Template Registry
|
||||
|
||||
```
|
||||
app/modules/hosting/templates_library/
|
||||
├── manifest.json # Registry of all templates
|
||||
├── restaurant/
|
||||
│ ├── meta.json # Name, description, preview image, tags
|
||||
│ ├── pages/ # ContentPage seed data (JSON per page)
|
||||
│ │ ├── homepage.json # Sections: hero, menu-highlights, testimonials, reservation-cta
|
||||
│ │ ├── about.json # Our story, team, values
|
||||
│ │ ├── menu.json # Menu categories with placeholder items
|
||||
│ │ └── contact.json # Map, hours, reservation form
|
||||
│ └── theme.json # Colors, fonts, layout config
|
||||
├── construction/
|
||||
│ ├── meta.json
|
||||
│ ├── pages/
|
||||
│ │ ├── homepage.json # Sections: hero, services, projects-gallery, testimonials, cta
|
||||
│ │ ├── services.json # Service cards (renovation, new build, etc.)
|
||||
│ │ ├── projects.json # Portfolio gallery
|
||||
│ │ └── contact.json # Quote request form, address, phone
|
||||
│ └── theme.json
|
||||
├── auto-parts/
|
||||
│ ├── meta.json
|
||||
│ ├── pages/
|
||||
│ │ ├── homepage.json # Sections: hero, brands, categories, promotions
|
||||
│ │ ├── catalog.json # Category grid
|
||||
│ │ └── contact.json # Store locations, hours
|
||||
│ └── theme.json
|
||||
├── professional-services/ # Lawyers, accountants, consultants
|
||||
│ ├── meta.json
|
||||
│ ├── pages/
|
||||
│ │ ├── homepage.json # Sections: hero, expertise, team, testimonials
|
||||
│ │ ├── services.json
|
||||
│ │ ├── team.json
|
||||
│ │ └── contact.json
|
||||
│ └── theme.json
|
||||
└── generic/ # Fallback for any industry
|
||||
├── meta.json
|
||||
├── pages/
|
||||
│ ├── homepage.json
|
||||
│ ├── about.json
|
||||
│ └── contact.json
|
||||
└── theme.json
|
||||
```
|
||||
|
||||
### Template Meta Format
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "restaurant",
|
||||
"name": "Restaurant & Dining",
|
||||
"description": "Elegant template for restaurants, cafés, and bars",
|
||||
"preview_image": "restaurant-preview.jpg",
|
||||
"tags": ["food", "dining", "hospitality"],
|
||||
"languages": ["en", "fr", "de"],
|
||||
"pages": ["homepage", "about", "menu", "contact"],
|
||||
"default_theme": {
|
||||
"theme_name": "modern",
|
||||
"colors": {
|
||||
"primary": "#b45309",
|
||||
"secondary": "#78350f",
|
||||
"accent": "#f59e0b",
|
||||
"background": "#fffbeb",
|
||||
"text": "#1c1917"
|
||||
},
|
||||
"font_family_heading": "Playfair Display",
|
||||
"font_family_body": "Inter",
|
||||
"layout_style": "grid",
|
||||
"header_style": "transparent"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Page Section Format (homepage.json example)
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "homepage",
|
||||
"title": "{{business_name}}",
|
||||
"template": "full",
|
||||
"sections": {
|
||||
"hero": {
|
||||
"type": "hero",
|
||||
"headline": "{{business_name}}",
|
||||
"subheadline": "Quality dining in {{city}}",
|
||||
"cta_text": "Reserve a Table",
|
||||
"cta_link": "/contact",
|
||||
"background_image": "placeholder-restaurant-hero.jpg"
|
||||
},
|
||||
"features": {
|
||||
"type": "features_grid",
|
||||
"title": "Our Specialties",
|
||||
"items": [
|
||||
{"icon": "utensils", "title": "Fine Dining", "description": "..."},
|
||||
{"icon": "wine-glass", "title": "Wine Selection", "description": "..."},
|
||||
{"icon": "cake", "title": "Pastry", "description": "..."}
|
||||
]
|
||||
},
|
||||
"testimonials": {
|
||||
"type": "testimonials",
|
||||
"title": "What Our Guests Say",
|
||||
"items": []
|
||||
},
|
||||
"cta": {
|
||||
"type": "cta_banner",
|
||||
"headline": "Ready to visit?",
|
||||
"cta_text": "Contact Us",
|
||||
"cta_link": "/contact"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`{{business_name}}`, `{{city}}`, `{{phone}}`, `{{email}}` are replaced with prospect data at POC creation time.
|
||||
|
||||
## What Needs to Be Built
|
||||
|
||||
### Phase 1 — Template Infrastructure (in hosting module)
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `hosting/templates_library/` directory | Template files (JSON) |
|
||||
| `hosting/services/template_service.py` | Load manifest, list templates, validate |
|
||||
| `hosting/services/poc_builder_service.py` | Apply template to Store: create ContentPages, set StoreTheme, replace placeholders with prospect data |
|
||||
| `hosting/schemas/template.py` | `TemplateListResponse`, `TemplateDetailResponse` |
|
||||
| API: `GET /admin/hosting/templates` | List available templates with previews |
|
||||
| API: `POST /admin/hosting/poc/build` | Build POC: `{prospect_id, template_id, merchant_id}` |
|
||||
|
||||
### Phase 2 — Template Content
|
||||
|
||||
Create 5 industry templates:
|
||||
|
||||
1. **Restaurant** — hero, menu highlights, testimonials, reservation CTA
|
||||
2. **Construction** — hero, services, project gallery, quote CTA
|
||||
3. **Auto Parts** — hero, brand grid, product categories, store locator
|
||||
4. **Professional Services** — hero, expertise areas, team, case studies
|
||||
5. **Generic** — clean, minimal, works for any business
|
||||
|
||||
Each template needs:
|
||||
- 3-4 page JSONs with sections
|
||||
- Theme JSON (colors, fonts)
|
||||
- Meta JSON (name, description, tags, preview)
|
||||
- Placeholder images (can use free stock photos initially)
|
||||
|
||||
### Phase 3 — Storefront Rendering Enhancement
|
||||
|
||||
The existing storefront templates render `ContentPage.sections` as HTML. Verify:
|
||||
- All section types used by templates are supported by the storefront renderer
|
||||
- Homepage sections (hero, features_grid, testimonials, cta_banner) render correctly
|
||||
- Theme colors/fonts apply to the storefront
|
||||
|
||||
If new section types are needed (e.g., `menu`, `project_gallery`, `team_grid`), add them to the storefront section renderer.
|
||||
|
||||
## What Already Exists (No Work Needed)
|
||||
|
||||
- Store creation with subdomain
|
||||
- StoreDomain custom domain assignment + verification
|
||||
- StoreContextMiddleware (subdomain + custom domain + path-based routing)
|
||||
- CMS ContentPage three-tier hierarchy
|
||||
- StoreTheme with colors, fonts, layout, custom CSS
|
||||
- MediaFile upload and serving
|
||||
- Storefront page rendering
|
||||
- Caddy wildcard SSL for `*.hostwizard.lu`
|
||||
- Cloudflare proxy + WAF for custom domains
|
||||
- `go_live()` already calls `store_domain_service.add_domain()`
|
||||
|
||||
## Relationship to Other Proposals
|
||||
|
||||
- **`hosting-site-creation-fix.md`**: Must be implemented first — site creation needs merchant_id or prospect_id (no system merchant hack)
|
||||
- **`security-audit-demo-poc-builder.md` Phase 4**: This document replaces Phase 4 with a more detailed architecture. The POC builder is now "apply industry template to Store CMS" rather than a vague "build better site"
|
||||
48
docs/proposals/hosting-cascade-delete.md
Normal file
48
docs/proposals/hosting-cascade-delete.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Hosting: Cascade Delete HostedSite → Store
|
||||
|
||||
## Problem
|
||||
|
||||
Deleting a HostedSite leaves the associated Store orphaned in the database. The Store's subdomain remains taken, so creating a new site with the same prospect/business fails with "slug already exists".
|
||||
|
||||
## Root Cause
|
||||
|
||||
- `hosted_site_service.delete()` does a hard delete on the HostedSite only
|
||||
- The Store (created by `hosted_site_service.create()`) is not deleted
|
||||
- `stores.subdomain` has a partial unique index (`WHERE deleted_at IS NULL`)
|
||||
- The orphaned Store is still active → subdomain collision on re-create
|
||||
|
||||
## Recommendation
|
||||
|
||||
When deleting a HostedSite, also delete the associated Store:
|
||||
|
||||
```python
|
||||
def delete(self, db: Session, site_id: int) -> bool:
|
||||
site = self.get_by_id(db, site_id)
|
||||
store = site.store
|
||||
db.delete(site)
|
||||
if store:
|
||||
# Soft-delete or hard-delete the store created for this site
|
||||
soft_delete(store) # or db.delete(store)
|
||||
db.flush()
|
||||
```
|
||||
|
||||
### Considerations
|
||||
|
||||
- **Soft vs hard delete**: If using soft-delete, the subdomain gets freed (partial unique index filters `deleted_at IS NULL`). If hard-deleting, cascade will also remove StorePlatform, ContentPages, StoreTheme, etc.
|
||||
- **CMS content**: Deleting the Store cascades to ContentPages (created by POC builder) — this is desired since the POC content belongs to that store
|
||||
- **Merchant**: The merchant created from the prospect should NOT be deleted — it may be used by other stores or relinked later
|
||||
- **Safety**: Only delete stores that were created by the hosting module (check if store has a HostedSite backref). Don't delete stores that existed independently.
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `hosting/services/hosted_site_service.py` | Update `delete()` to also soft-delete/hard-delete the associated Store |
|
||||
| `hosting/tests/unit/test_hosted_site_service.py` | Update delete test to verify Store is also deleted |
|
||||
|
||||
## Quick workaround (for now)
|
||||
|
||||
Manually delete the orphaned store from the DB:
|
||||
```sql
|
||||
DELETE FROM stores WHERE subdomain = 'batirenovation-strasbourg' AND id NOT IN (SELECT store_id FROM hosted_sites);
|
||||
```
|
||||
145
docs/proposals/security-audit-demo-poc-builder.md
Normal file
145
docs/proposals/security-audit-demo-poc-builder.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Security Audit + Live Demo + POC Builder Integration
|
||||
|
||||
## Context
|
||||
|
||||
The prospecting module scans websites for tech stack, performance, and contacts — but not security posture. Meanwhile, `scripts/security-audit/` has a standalone audit tool (audit.py) that checks HTTPS, headers, exposed files, cookies, etc., and a demo tool (demo.py) that clones a site and lets you demonstrate XSS/cookie theft/defacement live.
|
||||
|
||||
**Goal:** Integrate both into the platform as proper module features, plus architect a future POC site builder.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Phase 1 foundation: `ProspectSecurityAudit` model, `last_security_audit_at` column, relationship on Prospect
|
||||
- [ ] Phase 1 remaining: constants, service, migration, endpoints, frontend
|
||||
- [ ] Phase 2: report generation
|
||||
- [ ] Phase 3: live demo server
|
||||
- [ ] Phase 4: POC builder (architecture only)
|
||||
|
||||
## Phase 1: Security Audit in Enrichment Pipeline
|
||||
|
||||
Slot security audit into the existing scan pipeline (alongside http-check, tech-scan, performance, contact-scrape).
|
||||
|
||||
### New files to create
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `prospecting/models/security_audit.py` | **DONE** — `ProspectSecurityAudit` model (1:1 on Prospect) |
|
||||
| `prospecting/services/security_audit_service.py` | `SecurityAuditService.run_audit(db, prospect)` — migrates `SecurityAudit` class logic from `scripts/security-audit/audit.py` (check_https, check_ssl, check_headers, check_exposed_files, check_cookies, check_server_info, check_technology) |
|
||||
| `prospecting/services/security_audit_constants.py` | TRANSLATIONS, EXPOSED_PATHS, SECURITY_HEADERS, SEVERITY_SCORES, GRADE_COLORS moved from audit.py |
|
||||
| `prospecting/schemas/security_audit.py` | Pydantic schemas: `SecurityAuditResponse`, `SecurityAuditSingleResponse`, `SecurityAuditBatchResponse` |
|
||||
| `prospecting/migrations/versions/prospecting_002_security_audit.py` | Creates `prospect_security_audits` table, adds `last_security_audit_at` to `prospects` |
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `prospecting/models/prospect.py` | **DONE** — `last_security_audit_at` column + `security_audit` relationship |
|
||||
| `prospecting/models/__init__.py` | **DONE** — Export `ProspectSecurityAudit` |
|
||||
| `prospecting/services/prospect_service.py` | Add `get_pending_security_audit()` query |
|
||||
| `prospecting/routes/api/admin_enrichment.py` | Add `/security-audit/batch` + `/security-audit/{prospect_id}` endpoints. Add to `full_enrichment` pipeline. |
|
||||
| `prospecting/tasks/scan_tasks.py` | Add `batch_security_audit` Celery task + add to `full_enrichment` task |
|
||||
| `prospecting/static/admin/js/scan-jobs.js` | Add `security_audit: 'security-audit'` to batchRoutes |
|
||||
| `prospecting/templates/.../scan-jobs.html` | Add "Security Audit" batch button |
|
||||
| `prospecting/templates/.../prospect-detail.html` | Add "Security" tab with grade badge, score bar, findings by severity, "Run Audit" button |
|
||||
| `prospecting/static/admin/js/prospect-detail.js` | Add `runSecurityAudit()`, `severityColor()`, `gradeColor()` methods, `security_audit` tab handling |
|
||||
| `prospecting/schemas/prospect.py` | Add `security_audit` field to `ProspectDetailResponse` |
|
||||
|
||||
### Key design decisions
|
||||
- Findings stored as JSON (same as `tech_stack_json`, `lighthouse_json`) — variable structure
|
||||
- Denormalized severity counts for dashboard queries without JSON parsing
|
||||
- Reuse same `ScanBatchResponse` schema for batch endpoint
|
||||
- `SECURITY_AUDIT` already exists in `JobType` enum — no enum change needed
|
||||
|
||||
## Phase 2: Security Report Generation
|
||||
|
||||
Generate branded bilingual HTML reports from stored audit data.
|
||||
|
||||
### New files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `prospecting/services/security_report_service.py` | `generate_html_report(audit, prospect, language, contact)` → standalone HTML string. Migrates `generate_report()` from audit.py (~300 lines of HTML template). |
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `prospecting/routes/api/admin_enrichment.py` | Add `GET /security-audit/{prospect_id}/report?language=auto&download=false` → `HTMLResponse` |
|
||||
| `prospecting/templates/.../prospect-detail.html` | "Generate Report" button + language selector on security tab |
|
||||
| `prospecting/static/admin/js/prospect-detail.js` | `openSecurityReport(lang)` method |
|
||||
|
||||
## Phase 3: Live Demo Server
|
||||
|
||||
Integrate `demo.py`'s site cloner + hacker console as a managed background process.
|
||||
|
||||
### New files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `prospecting/models/demo_session.py` | `DemoSession` model — prospect_id, status, port, pid, target_url, target_domain, started_at, stopped_at, error_message |
|
||||
| `prospecting/services/demo_service.py` | `DemoService` — start_demo (clone + spawn server), stop_demo, stop_all, get_active. Uses `multiprocessing.Process` to run HTTPServer. Port range 9000-9099. |
|
||||
| `prospecting/services/demo_constants.py` | HACKER_JS, HACKER_HTML_TEMPLATE, VULNERABLE_SEARCH_BAR, SPA_SCRIPT_DOMAINS migrated from demo.py |
|
||||
| `prospecting/schemas/demo_session.py` | `DemoSessionResponse` (includes computed hacker_url, site_url) |
|
||||
| `prospecting/routes/api/admin_demo.py` | `POST /demo/start/{prospect_id}`, `POST /demo/stop/{session_id}`, `GET /demo/active`, `POST /demo/stop-all` |
|
||||
| `prospecting/migrations/versions/prospecting_003_demo_sessions.py` | Creates `prospect_demo_sessions` table |
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `prospecting/routes/api/admin.py` | Include demo router |
|
||||
| `prospecting/config.py` | Add `demo_port_range_start/end`, `demo_max_concurrent`, `demo_auto_stop_minutes` |
|
||||
| `prospecting/templates/.../prospect-detail.html` | "Launch Demo" / "Stop Demo" buttons + active demo indicator on security tab |
|
||||
| `prospecting/static/admin/js/prospect-detail.js` | `startDemo()`, `stopDemo()`, `openHackerConsole()`, periodic status check |
|
||||
|
||||
### Key design decisions
|
||||
- `multiprocessing.Process` (not subprocess) — can import SiteCloner/DemoHandler directly
|
||||
- Auto-stop after 60 minutes (configurable)
|
||||
- On app startup: cleanup stale RUNNING sessions from previous crashes
|
||||
- Max 5 concurrent demos (configurable)
|
||||
|
||||
## Phase 4: POC Site Builder (Architecture Only — No Implementation)
|
||||
|
||||
Separate from security demo. Builds a better version of the client's site as proof-of-concept.
|
||||
|
||||
### Where it fits
|
||||
- Triggered FROM prospect detail page ("Build POC Site" button)
|
||||
- Creates artifacts IN the hosting module (`HostedSite` with status=DRAFT → POC_READY)
|
||||
- Uses `HostedSite.prospect_id` FK (already exists) to link back
|
||||
|
||||
### Planned structure (future work)
|
||||
- `hosting/services/poc_builder_service.py` — `create_poc(db, prospect_id, template, config)`
|
||||
- `hosting/poc_templates/` — directory of starter templates with manifest
|
||||
- `POST /admin/hosting/poc/build/{prospect_id}` — trigger POC generation
|
||||
- `GET /admin/hosting/poc/templates` — list available templates
|
||||
- HostedSite additions: `poc_template`, `poc_source_audit_id`, `poc_config_json` columns
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```
|
||||
Phase 1 (security audit pipeline) ← IN PROGRESS
|
||||
↓
|
||||
Phase 2 (report generation) ← depends on Phase 1 model
|
||||
↓ (can parallel with Phase 3)
|
||||
Phase 3 (live demo server) ← independent, but logically follows
|
||||
↓
|
||||
Phase 4 (POC builder) ← architecture only, deferred
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Phase 1
|
||||
python -m pytest app/modules/prospecting/tests/ -x -q --timeout=30
|
||||
# Manual: Run enrichment on prospect 1, check security tab shows grade/findings
|
||||
curl -X POST .../enrichment/security-audit/1 # single
|
||||
curl -X POST .../enrichment/security-audit/batch # batch
|
||||
|
||||
# Phase 2
|
||||
# Open report in browser:
|
||||
curl .../enrichment/security-audit/1/report > report.html && open report.html
|
||||
|
||||
# Phase 3
|
||||
# Start demo, open hacker console, verify attacks work, stop demo
|
||||
curl -X POST .../demo/start/1 # returns port + URLs
|
||||
curl -X POST .../demo/stop/{session_id}
|
||||
```
|
||||
17
main.py
17
main.py
@@ -491,6 +491,23 @@ for route_info in storefront_page_routes:
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HOSTING PUBLIC PAGES (POC preview)
|
||||
# =============================================================================
|
||||
try:
|
||||
from app.modules.hosting.routes.pages.public import router as hosting_public_router
|
||||
|
||||
app.include_router(
|
||||
hosting_public_router,
|
||||
prefix="",
|
||||
tags=["hosting-public"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
logger.info("Registered hosting public page routes")
|
||||
except ImportError:
|
||||
pass # Hosting module not installed
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PLATFORM ROUTING (via PlatformContextMiddleware)
|
||||
#
|
||||
|
||||
@@ -128,6 +128,21 @@ class StorefrontAccessMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
store = getattr(request.state, "store", None)
|
||||
|
||||
# Case 0: Preview token bypass (POC site previews)
|
||||
preview_token = request.query_params.get("_preview")
|
||||
if preview_token and store:
|
||||
from app.core.preview_token import verify_preview_token
|
||||
|
||||
if verify_preview_token(preview_token, store.id):
|
||||
request.state.is_preview = True
|
||||
request.state.subscription = None
|
||||
request.state.subscription_tier = None
|
||||
logger.info(
|
||||
"[STOREFRONT_ACCESS] Preview token valid for store '%s'",
|
||||
store.subdomain,
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
# Case 1: No store detected at all
|
||||
if not store:
|
||||
return self._render_unavailable(request, "not_found")
|
||||
|
||||
@@ -21,6 +21,7 @@ python-multipart==0.0.20
|
||||
# Data processing
|
||||
pandas==2.2.3
|
||||
requests==2.32.3
|
||||
beautifulsoup4==4.14.3
|
||||
|
||||
# Image processing
|
||||
Pillow>=10.0.0
|
||||
|
||||
Reference in New Issue
Block a user