feat(hosting): add HostWizard platform module and fix migration chain
Some checks failed
Some checks failed
- 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:
1
app/modules/hosting/routes/api/__init__.py
Normal file
1
app/modules/hosting/routes/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/hosting/routes/api/__init__.py
|
||||
21
app/modules/hosting/routes/api/admin.py
Normal file
21
app/modules/hosting/routes/api/admin.py
Normal 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"])
|
||||
@@ -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")
|
||||
|
||||
210
app/modules/hosting/routes/api/admin_sites.py
Normal file
210
app/modules/hosting/routes/api/admin_sites.py
Normal 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)
|
||||
26
app/modules/hosting/routes/api/admin_stats.py
Normal file
26
app/modules/hosting/routes/api/admin_stats.py
Normal 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)
|
||||
Reference in New Issue
Block a user