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>
260 lines
9.6 KiB
Python
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()
|