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/services/__init__.py
Normal file
1
app/modules/hosting/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/hosting/services/__init__.py
|
||||
128
app/modules/hosting/services/client_service_service.py
Normal file
128
app/modules/hosting/services/client_service_service.py
Normal 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()
|
||||
325
app/modules/hosting/services/hosted_site_service.py
Normal file
325
app/modules/hosting/services/hosted_site_service.py
Normal 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()
|
||||
101
app/modules/hosting/services/stats_service.py
Normal file
101
app/modules/hosting/services/stats_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user