diff --git a/app/modules/hosting/routes/api/admin_sites.py b/app/modules/hosting/routes/api/admin_sites.py index 065d0c99..9c3d73ff 100644 --- a/app/modules/hosting/routes/api/admin_sites.py +++ b/app/modules/hosting/routes/api/admin_sites.py @@ -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( diff --git a/app/modules/hosting/services/poc_builder_service.py b/app/modules/hosting/services/poc_builder_service.py new file mode 100644 index 00000000..65d60609 --- /dev/null +++ b/app/modules/hosting/services/poc_builder_service.py @@ -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"

{slug.title()} page content

", + 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()