Files
orion/app/modules/hosting/routes/api/admin_sites.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

278 lines
8.6 KiB
Python

# app/modules/hosting/routes/api/admin_sites.py
"""
Admin API routes for hosted site management.
"""
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
from app.core.database import get_db
from app.modules.hosting.schemas.hosted_site import (
AcceptProposalRequest,
GoLiveRequest,
HostedSiteCreate,
HostedSiteDeleteResponse,
HostedSiteDetailResponse,
HostedSiteListResponse,
HostedSiteResponse,
HostedSiteUpdate,
SendProposalRequest,
)
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
router = APIRouter(prefix="/sites")
logger = logging.getLogger(__name__)
@router.get("/templates", response_model=TemplateListResponse)
def list_templates(
current_admin: UserContext = Depends(get_current_admin_api),
):
"""List available industry templates for POC site generation."""
templates = template_service.list_templates()
return TemplateListResponse(
templates=[TemplateResponse(**t) for t in templates],
)
class PreviewUrlResponse(BaseModel):
"""Response with signed preview URL."""
preview_url: str
expires_in_hours: int = 24
@router.get("/sites/{site_id}/preview-url", response_model=PreviewUrlResponse)
def get_preview_url(
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Generate a signed preview URL for a hosted site."""
from app.core.preview_token import create_preview_token
site = hosted_site_service.get_by_id(db, site_id)
store = site.store
subdomain = store.subdomain or store.store_code
token = create_preview_token(store.id, subdomain, site.id)
return PreviewUrlResponse(
preview_url=f"/storefront/{subdomain}/?_preview={token}",
)
class BuildPocRequest(BaseModel):
"""Request to build a POC site from prospect + template."""
prospect_id: int
template_id: str
merchant_id: int | None = None
site_id: int | None = None # If set, populate existing site instead of creating new one
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,
site_id=data.site_id,
)
db.commit()
return BuildPocResponse(**result)
def _to_response(site) -> HostedSiteResponse:
"""Convert a hosted site model to response schema."""
return HostedSiteResponse(
id=site.id,
store_id=site.store_id,
prospect_id=site.prospect_id,
status=site.status.value if hasattr(site.status, "value") else str(site.status),
business_name=site.business_name,
contact_name=site.contact_name,
contact_email=site.contact_email,
contact_phone=site.contact_phone,
proposal_sent_at=site.proposal_sent_at,
proposal_accepted_at=site.proposal_accepted_at,
went_live_at=site.went_live_at,
proposal_notes=site.proposal_notes,
live_domain=site.live_domain,
internal_notes=site.internal_notes,
created_at=site.created_at,
updated_at=site.updated_at,
)
@router.get("", response_model=HostedSiteListResponse)
def list_sites(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
search: str | None = Query(None),
status: str | None = Query(None),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""List hosted sites with filters and pagination."""
sites, total = hosted_site_service.get_all(
db, page=page, per_page=per_page, search=search, status=status,
)
return HostedSiteListResponse(
items=[_to_response(s) for s in sites],
total=total,
page=page,
per_page=per_page,
pages=ceil(total / per_page) if per_page else 0,
)
@router.get("/{site_id}", response_model=HostedSiteDetailResponse)
def get_site(
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get full hosted site detail with services."""
site = hosted_site_service.get_by_id(db, site_id)
return site
@router.post("", response_model=HostedSiteResponse)
def create_site(
data: HostedSiteCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Create a new hosted site (auto-creates Store)."""
site = hosted_site_service.create(db, data.model_dump(exclude_none=True))
db.commit()
return _to_response(site)
@router.put("/{site_id}", response_model=HostedSiteResponse)
def update_site(
data: HostedSiteUpdate,
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Update a hosted site."""
site = hosted_site_service.update(db, site_id, data.model_dump(exclude_none=True))
db.commit()
return _to_response(site)
@router.delete("/{site_id}", response_model=HostedSiteDeleteResponse)
def delete_site(
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Delete a hosted site."""
hosted_site_service.delete(db, site_id)
db.commit()
return HostedSiteDeleteResponse(message="Hosted site deleted")
# ── Lifecycle endpoints ────────────────────────────────────────────────
@router.post("/{site_id}/mark-poc-ready", response_model=HostedSiteResponse)
def mark_poc_ready(
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Mark site as POC ready."""
site = hosted_site_service.mark_poc_ready(db, site_id)
db.commit()
return _to_response(site)
@router.post("/{site_id}/send-proposal", response_model=HostedSiteResponse)
def send_proposal(
data: SendProposalRequest,
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Send proposal to prospect."""
site = hosted_site_service.send_proposal(db, site_id, notes=data.notes)
db.commit()
return _to_response(site)
@router.post("/{site_id}/accept", response_model=HostedSiteResponse)
def accept_proposal(
data: AcceptProposalRequest,
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Accept proposal: create/link merchant, create subscription."""
site = hosted_site_service.accept_proposal(db, site_id, merchant_id=data.merchant_id)
db.commit()
return _to_response(site)
@router.post("/{site_id}/go-live", response_model=HostedSiteResponse)
def go_live(
data: GoLiveRequest,
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Go live with a domain."""
site = hosted_site_service.go_live(db, site_id, domain=data.domain)
db.commit()
return _to_response(site)
@router.post("/{site_id}/suspend", response_model=HostedSiteResponse)
def suspend_site(
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Suspend a site."""
site = hosted_site_service.suspend(db, site_id)
db.commit()
return _to_response(site)
@router.post("/{site_id}/cancel", response_model=HostedSiteResponse)
def cancel_site(
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Cancel a site."""
site = hosted_site_service.cancel(db, site_id)
db.commit()
return _to_response(site)