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/services/__init__.py

View File

@@ -0,0 +1,128 @@
# app/modules/hosting/services/client_service_service.py
"""
Client service CRUD service.
Manages operational tracking for hosted site services (domains, email, SSL, etc.).
"""
import logging
from datetime import UTC, datetime, timedelta
from sqlalchemy.orm import Session
from app.modules.hosting.exceptions import ClientServiceNotFoundException
from app.modules.hosting.models import (
ClientService,
ClientServiceStatus,
ServiceType,
)
logger = logging.getLogger(__name__)
class ClientServiceService:
"""Service for client service CRUD operations."""
def get_by_id(self, db: Session, service_id: int) -> ClientService:
service = db.query(ClientService).filter(ClientService.id == service_id).first()
if not service:
raise ClientServiceNotFoundException(str(service_id))
return service
def get_for_site(self, db: Session, hosted_site_id: int) -> list[ClientService]:
return (
db.query(ClientService)
.filter(ClientService.hosted_site_id == hosted_site_id)
.order_by(ClientService.created_at.desc())
.all()
)
def create(self, db: Session, hosted_site_id: int, data: dict) -> ClientService:
service = ClientService(
hosted_site_id=hosted_site_id,
service_type=ServiceType(data["service_type"]),
name=data["name"],
description=data.get("description"),
status=ClientServiceStatus.PENDING,
billing_period=data.get("billing_period"),
price_cents=data.get("price_cents"),
currency=data.get("currency", "EUR"),
addon_product_id=data.get("addon_product_id"),
domain_name=data.get("domain_name"),
registrar=data.get("registrar"),
mailbox_count=data.get("mailbox_count"),
expires_at=data.get("expires_at"),
period_start=data.get("period_start"),
period_end=data.get("period_end"),
auto_renew=data.get("auto_renew", True),
notes=data.get("notes"),
)
db.add(service)
db.flush()
logger.info("Created client service: %s (site_id=%d)", service.name, hosted_site_id)
return service
def update(self, db: Session, service_id: int, data: dict) -> ClientService:
service = self.get_by_id(db, service_id)
for field in [
"name", "description", "status", "billing_period", "price_cents",
"currency", "addon_product_id", "domain_name", "registrar",
"mailbox_count", "expires_at", "period_start", "period_end",
"auto_renew", "notes",
]:
if field in data and data[field] is not None:
setattr(service, field, data[field])
db.flush()
return service
def delete(self, db: Session, service_id: int) -> bool:
service = self.get_by_id(db, service_id)
db.delete(service)
db.flush()
logger.info("Deleted client service: %d", service_id)
return True
def get_expiring_soon(self, db: Session, days: int = 30) -> list[ClientService]:
"""Get services expiring within the given number of days."""
cutoff = datetime.now(UTC) + timedelta(days=days)
return (
db.query(ClientService)
.filter(
ClientService.expires_at.isnot(None),
ClientService.expires_at <= cutoff,
ClientService.status == ClientServiceStatus.ACTIVE,
)
.order_by(ClientService.expires_at.asc())
.all()
)
def get_all(
self,
db: Session,
*,
page: int = 1,
per_page: int = 20,
service_type: str | None = None,
status: str | None = None,
) -> tuple[list[ClientService], int]:
"""Get all services with pagination and filters."""
query = db.query(ClientService)
if service_type:
query = query.filter(ClientService.service_type == service_type)
if status:
query = query.filter(ClientService.status == status)
total = query.count()
services = (
query.order_by(ClientService.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
return services, total
client_service_service = ClientServiceService()

View File

@@ -0,0 +1,325 @@
# app/modules/hosting/services/hosted_site_service.py
"""
Hosted site service — CRUD + lifecycle management.
Manages the POC → live website pipeline:
draft → poc_ready → proposal_sent → accepted → live → suspended | cancelled
"""
import logging
import re
from datetime import UTC, datetime
from sqlalchemy import or_
from sqlalchemy.orm import Session, joinedload
from app.modules.hosting.exceptions import (
DuplicateSlugException,
HostedSiteNotFoundException,
InvalidStatusTransitionException,
)
from app.modules.hosting.models import HostedSite, HostedSiteStatus
logger = logging.getLogger(__name__)
# Valid status transitions
ALLOWED_TRANSITIONS: dict[HostedSiteStatus, list[HostedSiteStatus]] = {
HostedSiteStatus.DRAFT: [HostedSiteStatus.POC_READY, HostedSiteStatus.CANCELLED],
HostedSiteStatus.POC_READY: [HostedSiteStatus.PROPOSAL_SENT, HostedSiteStatus.CANCELLED],
HostedSiteStatus.PROPOSAL_SENT: [HostedSiteStatus.ACCEPTED, HostedSiteStatus.CANCELLED],
HostedSiteStatus.ACCEPTED: [HostedSiteStatus.LIVE, HostedSiteStatus.CANCELLED],
HostedSiteStatus.LIVE: [HostedSiteStatus.SUSPENDED, HostedSiteStatus.CANCELLED],
HostedSiteStatus.SUSPENDED: [HostedSiteStatus.LIVE, HostedSiteStatus.CANCELLED],
HostedSiteStatus.CANCELLED: [],
}
def _slugify(name: str) -> str:
"""Generate a URL-safe slug from a business name."""
slug = name.lower().strip()
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
slug = re.sub(r"[\s-]+", "-", slug)
return slug.strip("-")[:50]
class HostedSiteService:
"""Service for hosted site CRUD and lifecycle operations."""
def get_by_id(self, db: Session, site_id: int) -> HostedSite:
site = (
db.query(HostedSite)
.options(joinedload(HostedSite.client_services))
.filter(HostedSite.id == site_id)
.first()
)
if not site:
raise HostedSiteNotFoundException(str(site_id))
return site
def get_all(
self,
db: Session,
*,
page: int = 1,
per_page: int = 20,
search: str | None = None,
status: str | None = None,
) -> tuple[list[HostedSite], int]:
query = db.query(HostedSite).options(joinedload(HostedSite.client_services))
if search:
query = query.filter(
or_(
HostedSite.business_name.ilike(f"%{search}%"),
HostedSite.contact_email.ilike(f"%{search}%"),
HostedSite.live_domain.ilike(f"%{search}%"),
)
)
if status:
query = query.filter(HostedSite.status == status)
total = query.count()
sites = (
query.order_by(HostedSite.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
return sites, total
def create(self, db: Session, data: dict) -> HostedSite:
"""Create a hosted site with an auto-created Store on the hosting platform."""
from app.modules.tenancy.models import Platform
from app.modules.tenancy.schemas.store import StoreCreate
from app.modules.tenancy.services.admin_service import admin_service
business_name = data["business_name"]
slug = _slugify(business_name)
# Find hosting platform
platform = db.query(Platform).filter(Platform.code == "hosting").first()
if not platform:
raise ValueError("Hosting platform not found. Run init_production first.")
# Create a temporary merchant-less store requires a merchant_id.
# For POC sites we create a placeholder: the store is re-assigned on accept_proposal.
# Use the platform's own admin store or create under a system merchant.
# For now, create store via AdminService which handles defaults.
store_code = slug.upper().replace("-", "_")[:50]
subdomain = slug
# Check for duplicate subdomain
from app.modules.tenancy.models import Store
existing = db.query(Store).filter(Store.subdomain == subdomain).first()
if existing:
raise DuplicateSlugException(subdomain)
# We need a system merchant for POC sites.
# Look for one or create if needed.
from app.modules.tenancy.models import Merchant
system_merchant = db.query(Merchant).filter(Merchant.name == "HostWizard System").first()
if not system_merchant:
system_merchant = Merchant(
name="HostWizard System",
contact_email="system@hostwizard.lu",
is_active=True,
is_verified=True,
)
db.add(system_merchant)
db.flush()
store_data = StoreCreate(
merchant_id=system_merchant.id,
store_code=store_code,
subdomain=subdomain,
name=business_name,
description=f"POC website for {business_name}",
platform_ids=[platform.id],
)
store = admin_service.create_store(db, store_data)
site = HostedSite(
store_id=store.id,
prospect_id=data.get("prospect_id"),
status=HostedSiteStatus.DRAFT,
business_name=business_name,
contact_name=data.get("contact_name"),
contact_email=data.get("contact_email"),
contact_phone=data.get("contact_phone"),
internal_notes=data.get("internal_notes"),
)
db.add(site)
db.flush()
logger.info("Created hosted site: %s (store_id=%d)", site.display_name, store.id)
return site
def create_from_prospect(self, db: Session, prospect_id: int) -> HostedSite:
"""Create a hosted site pre-filled from prospect data."""
from app.modules.prospecting.models import 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))
# Get primary contact info from prospect contacts
contacts = prospect.contacts or []
primary_email = next((c.value for c in contacts if c.contact_type == "email"), None)
primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), None)
contact_name = next((c.label for c in contacts if c.label), None)
data = {
"business_name": prospect.business_name or prospect.domain_name or f"Prospect #{prospect.id}",
"contact_name": contact_name,
"contact_email": primary_email,
"contact_phone": primary_phone,
"prospect_id": prospect.id,
}
return self.create(db, data)
def update(self, db: Session, site_id: int, data: dict) -> HostedSite:
site = self.get_by_id(db, site_id)
for field in ["business_name", "contact_name", "contact_email", "contact_phone", "internal_notes"]:
if field in data and data[field] is not None:
setattr(site, field, data[field])
db.flush()
return site
def delete(self, db: Session, site_id: int) -> bool:
site = self.get_by_id(db, site_id)
db.delete(site)
db.flush()
logger.info("Deleted hosted site: %d", site_id)
return True
# ── Lifecycle transitions ──────────────────────────────────────────────
def _transition(self, db: Session, site_id: int, target: HostedSiteStatus) -> HostedSite:
"""Validate and apply a status transition."""
site = self.get_by_id(db, site_id)
allowed = ALLOWED_TRANSITIONS.get(site.status, [])
if target not in allowed:
raise InvalidStatusTransitionException(site.status.value, target.value)
site.status = target
db.flush()
return site
def mark_poc_ready(self, db: Session, site_id: int) -> HostedSite:
site = self._transition(db, site_id, HostedSiteStatus.POC_READY)
logger.info("Site %d marked POC ready", site_id)
return site
def send_proposal(self, db: Session, site_id: int, notes: str | None = None) -> HostedSite:
site = self._transition(db, site_id, HostedSiteStatus.PROPOSAL_SENT)
site.proposal_sent_at = datetime.now(UTC)
if notes:
site.proposal_notes = notes
db.flush()
logger.info("Proposal sent for site %d", site_id)
return site
def accept_proposal(
self, db: Session, site_id: int, merchant_id: int | None = None
) -> HostedSite:
"""Accept proposal: create or link merchant, create subscription, mark converted."""
site = self._transition(db, site_id, HostedSiteStatus.ACCEPTED)
site.proposal_accepted_at = datetime.now(UTC)
from app.modules.tenancy.models import Merchant, Platform
if merchant_id:
# Link to existing merchant
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise ValueError(f"Merchant {merchant_id} not found")
else:
# Create new merchant from contact info
from app.modules.tenancy.schemas.merchant import MerchantCreate
from app.modules.tenancy.services.merchant_service import merchant_service
email = site.contact_email or f"contact-{site.id}@hostwizard.lu"
merchant_data = MerchantCreate(
name=site.business_name,
contact_email=email,
contact_phone=site.contact_phone,
owner_email=email,
)
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
db, merchant_data
)
logger.info("Created merchant %s for site %d", merchant.name, site_id)
# Re-assign store to the real merchant
site.store.merchant_id = merchant.id
db.flush()
# Create MerchantSubscription on hosting platform
platform = db.query(Platform).filter(Platform.code == "hosting").first()
if platform:
from app.modules.billing.services.subscription_service import (
subscription_service,
)
existing_sub = subscription_service.get_merchant_subscription(
db, merchant.id, platform.id
)
if not existing_sub:
subscription_service.create_merchant_subscription(
db,
merchant_id=merchant.id,
platform_id=platform.id,
tier_code="essential",
trial_days=0,
)
logger.info("Created subscription for merchant %d on hosting platform", merchant.id)
# Mark prospect as converted
if site.prospect_id:
from app.modules.prospecting.models import Prospect, ProspectStatus
prospect = db.query(Prospect).filter(Prospect.id == site.prospect_id).first()
if prospect and prospect.status != ProspectStatus.CONVERTED:
prospect.status = ProspectStatus.CONVERTED
db.flush()
db.flush()
logger.info("Proposal accepted for site %d (merchant=%d)", site_id, merchant.id)
return site
def go_live(self, db: Session, site_id: int, domain: str) -> HostedSite:
"""Go live: add domain to store, update site."""
site = self._transition(db, site_id, HostedSiteStatus.LIVE)
site.went_live_at = datetime.now(UTC)
site.live_domain = domain
# Add domain to store via StoreDomainService
from app.modules.tenancy.schemas.store_domain import StoreDomainCreate
from app.modules.tenancy.services.store_domain_service import (
store_domain_service,
)
domain_data = StoreDomainCreate(domain=domain, is_primary=True)
store_domain_service.add_domain(db, site.store_id, domain_data)
db.flush()
logger.info("Site %d went live on %s", site_id, domain)
return site
def suspend(self, db: Session, site_id: int) -> HostedSite:
site = self._transition(db, site_id, HostedSiteStatus.SUSPENDED)
logger.info("Site %d suspended", site_id)
return site
def cancel(self, db: Session, site_id: int) -> HostedSite:
site = self._transition(db, site_id, HostedSiteStatus.CANCELLED)
logger.info("Site %d cancelled", site_id)
return site
hosted_site_service = HostedSiteService()

View File

@@ -0,0 +1,101 @@
# app/modules/hosting/services/stats_service.py
"""
Statistics service for the hosting dashboard.
"""
import logging
from datetime import UTC, datetime, timedelta
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.modules.hosting.models import (
ClientService,
ClientServiceStatus,
HostedSite,
)
logger = logging.getLogger(__name__)
class StatsService:
"""Service for dashboard statistics and reporting."""
def get_dashboard_stats(self, db: Session) -> dict:
"""Get overview statistics for the hosting dashboard."""
total = db.query(func.count(HostedSite.id)).scalar() or 0
# Sites by status
status_results = (
db.query(HostedSite.status, func.count(HostedSite.id))
.group_by(HostedSite.status)
.all()
)
sites_by_status = {
status.value if hasattr(status, "value") else str(status): count
for status, count in status_results
}
live_count = sites_by_status.get("live", 0)
poc_count = (
sites_by_status.get("draft", 0)
+ sites_by_status.get("poc_ready", 0)
+ sites_by_status.get("proposal_sent", 0)
)
# Active services
active_services = (
db.query(func.count(ClientService.id))
.filter(ClientService.status == ClientServiceStatus.ACTIVE)
.scalar()
or 0
)
# Monthly revenue (sum of price_cents for active services with monthly billing)
monthly_revenue = (
db.query(func.sum(ClientService.price_cents))
.filter(
ClientService.status == ClientServiceStatus.ACTIVE,
)
.scalar()
or 0
)
# Upcoming renewals (next 30 days)
cutoff = datetime.now(UTC) + timedelta(days=30)
upcoming_renewals = (
db.query(func.count(ClientService.id))
.filter(
ClientService.expires_at.isnot(None),
ClientService.expires_at <= cutoff,
ClientService.status == ClientServiceStatus.ACTIVE,
)
.scalar()
or 0
)
# Services by type
type_results = (
db.query(ClientService.service_type, func.count(ClientService.id))
.filter(ClientService.status == ClientServiceStatus.ACTIVE)
.group_by(ClientService.service_type)
.all()
)
services_by_type = {
stype.value if hasattr(stype, "value") else str(stype): count
for stype, count in type_results
}
return {
"total_sites": total,
"live_sites": live_count,
"poc_sites": poc_count,
"sites_by_status": sites_by_status,
"active_services": active_services,
"monthly_revenue_cents": monthly_revenue,
"upcoming_renewals": upcoming_renewals,
"services_by_type": services_by_type,
}
stats_service = StatsService()