feat(hosting): add HostWizard platform module and fix migration chain
Some checks failed
CI / pytest (push) Failing after 49m20s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 10s

- Add complete hosting module (models, routes, schemas, services, templates, migrations)
- Add HostWizard platform to init_production seed (code=hosting, domain=hostwizard.lu)
- Fix cms_002 migration down_revision to z_unique_subdomain_domain
- Fix prospecting_001 migration to chain after cms_002 (remove branch label)
- Add hosting/prospecting version_locations to alembic.ini
- Fix admin_services delete endpoint to use proper response model
- Add hostwizard.lu to deployment docs (DNS, Caddy, Cloudflare)
- Add hosting and prospecting user journey docs to mkdocs nav

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:34:56 +01:00
parent 784bcb9d23
commit 8b147f53c6
46 changed files with 3907 additions and 13 deletions

View File

@@ -0,0 +1 @@
# app/modules/hosting/routes/api/__init__.py

View File

@@ -0,0 +1,21 @@
# app/modules/hosting/routes/api/admin.py
"""
Hosting module admin API routes.
Aggregates all admin hosting routes:
- /sites/* - Hosted site CRUD and lifecycle
- /sites/{id}/services/* - Client service management
- /stats/* - Dashboard statistics
"""
from fastapi import APIRouter
from .admin_services import router as admin_services_router
from .admin_sites import router as admin_sites_router
from .admin_stats import router as admin_stats_router
router = APIRouter()
router.include_router(admin_sites_router, tags=["hosting-sites"])
router.include_router(admin_services_router, tags=["hosting-services"])
router.include_router(admin_stats_router, tags=["hosting-stats"])

View File

@@ -12,6 +12,7 @@ from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.hosting.schemas.client_service import (
ClientServiceCreate,
ClientServiceDeleteResponse,
ClientServiceResponse,
ClientServiceUpdate,
)
@@ -59,7 +60,7 @@ def update_service(
return service
@router.delete("/{service_id}")
@router.delete("/{service_id}", response_model=ClientServiceDeleteResponse)
def delete_service(
site_id: int = Path(...),
service_id: int = Path(...),
@@ -69,4 +70,4 @@ def delete_service(
"""Delete a client service."""
client_service_service.delete(db, service_id)
db.commit()
return {"message": "Service deleted"} # noqa: API001
return ClientServiceDeleteResponse(message="Service deleted")

View File

@@ -0,0 +1,210 @@
# 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 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.services.hosted_site_service import hosted_site_service
from app.modules.tenancy.schemas.auth import UserContext
router = APIRouter(prefix="/sites")
logger = logging.getLogger(__name__)
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.post("/from-prospect/{prospect_id}", response_model=HostedSiteResponse)
def create_from_prospect(
prospect_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Create a hosted site pre-filled from prospect data."""
site = hosted_site_service.create_from_prospect(db, prospect_id)
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)

View File

@@ -0,0 +1,26 @@
# app/modules/hosting/routes/api/admin_stats.py
"""
Admin API routes for hosting dashboard statistics.
"""
import logging
from fastapi import APIRouter, Depends
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.services.stats_service import stats_service
from app.modules.tenancy.schemas.auth import UserContext
router = APIRouter(prefix="/stats")
logger = logging.getLogger(__name__)
@router.get("/dashboard")
def get_dashboard_stats(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get dashboard statistics for the hosting module."""
return stats_service.get_dashboard_stats(db)