Files
orion/app/modules/hosting/services/poc_builder_service.py
Samir Boulahtit 83af32eb88 fix(hosting): POC builder works with existing sites
The Build POC button on site detail now passes site_id to the POC
builder, which populates the existing site's store with CMS content
instead of trying to create a new site (which failed with duplicate
slug error).

- poc_builder_service.build_poc() accepts optional site_id param
- If site_id given: uses existing site, skips hosted_site_service.create()
- If not given: creates new site (standalone POC build)
- API schema: added site_id to BuildPocRequest
- Frontend: passes this.site.id in the build request

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:10:39 +02:00

260 lines
9.6 KiB
Python

# 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,
site_id: int | None = None,
) -> dict:
"""Build a complete POC site from prospect data and a template.
If site_id is given, populates the existing site's store with CMS
content. Otherwise creates a new HostedSite + Store.
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. Use existing site or create new one
if site_id:
site = hosted_site_service.get_by_id(db, site_id)
else:
site_data = {
"business_name": context["business_name"],
"domain_name": prospect.domain_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()