feat(hosting): implement POC builder service (Workstream 3C)

One-click POC site generation from prospect data + industry template:

PocBuilderService.build_poc():
1. Loads prospect (scraped content, contacts, business info)
2. Loads industry template (pages, theme, sections)
3. Creates HostedSite + Store via hosted_site_service
4. Populates CMS ContentPages from template, replacing {{placeholders}}
   (business_name, city, phone, email, address, meta_description,
   about_paragraph) with prospect data
5. Applies StoreTheme (colors, fonts, layout) from template
6. Auto-transitions to POC_READY status

API: POST /admin/hosting/sites/poc/build
Body: {prospect_id, template_id, merchant_id?}

Tested: prospect 1 (batirenovation-strasbourg.fr) + "construction"
template → 4 pages created, theme applied, subdomain assigned.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 22:46:59 +02:00
parent 2e043260eb
commit bc951a36d9
2 changed files with 290 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ import logging
from math import ceil from math import ceil
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api from app.api.deps import get_current_admin_api
@@ -24,6 +25,7 @@ from app.modules.hosting.schemas.hosted_site import (
) )
from app.modules.hosting.schemas.template import TemplateListResponse, TemplateResponse 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.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.hosting.services.template_service import template_service
from app.modules.tenancy.schemas.auth import UserContext from app.modules.tenancy.schemas.auth import UserContext
@@ -42,6 +44,42 @@ def list_templates(
) )
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: def _to_response(site) -> HostedSiteResponse:
"""Convert a hosted site model to response schema.""" """Convert a hosted site model to response schema."""
return HostedSiteResponse( return HostedSiteResponse(

View File

@@ -0,0 +1,252 @@
# 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"],
"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()