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:
@@ -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
|
||||
@@ -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.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
|
||||
|
||||
@@ -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:
|
||||
"""Convert a hosted site model to response schema."""
|
||||
return HostedSiteResponse(
|
||||
|
||||
252
app/modules/hosting/services/poc_builder_service.py
Normal file
252
app/modules/hosting/services/poc_builder_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user