Files
orion/app/modules/hosting/services/hosted_site_service.py
Samir Boulahtit b51f9e8e30 fix(hosting): smart slug generation with fallback chain
Slugify now handles both domains and business names gracefully:
- Domain: strip protocol/www/TLD → batirenovation-strasbourg
- Business name: take first 3 meaningful words, skip filler
  (le, la, du, des, the, and) → boulangerie-coin
- Cap at 30 chars

Clients without a domain get clean slugs from their business name
instead of the full title truncated mid-word.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:56:28 +02:00

339 lines
13 KiB
Python

# 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, max_length: int = 30) -> str:
"""Generate a short URL-safe slug from a domain or business name.
Priority: domain name (clean) > first 3 words of business name > full slug truncated.
"""
slug = name.lower().strip()
# If it looks like a domain, extract the hostname part
for prefix in ["https://", "http://", "www."]:
if slug.startswith(prefix):
slug = slug[len(prefix):]
slug = slug.rstrip("/")
if "." in slug and " " not in slug:
# Domain: remove TLD → batirenovation-strasbourg.fr → batirenovation-strasbourg
slug = slug.rsplit(".", 1)[0]
else:
# Business name: take first 3 meaningful words for brevity
words = re.sub(r"[^a-z0-9\s]", "", slug).split()
# Skip filler words
filler = {"the", "le", "la", "les", "de", "du", "des", "et", "and", "und", "die", "der", "das"}
words = [w for w in words if w not in filler][:3]
slug = " ".join(words)
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
slug = re.sub(r"[\s-]+", "-", slug)
return slug.strip("-")[:max_length]
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.
Requires either merchant_id or prospect_id in data:
- merchant_id: store created under this merchant
- prospect_id: merchant auto-created from prospect data
"""
from app.modules.tenancy.models import Merchant, Platform, Store
from app.modules.tenancy.schemas.store import StoreCreate
from app.modules.tenancy.services.admin_service import admin_service
business_name = data["business_name"]
merchant_id = data.get("merchant_id")
prospect_id = data.get("prospect_id")
# Prefer domain_name for slug (shorter, cleaner), fall back to business_name
slug_source = data.get("domain_name") or business_name
slug = _slugify(slug_source)
# 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.")
# Resolve merchant
if merchant_id:
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise ValueError(f"Merchant {merchant_id} not found")
elif prospect_id:
merchant = self._create_merchant_from_prospect(db, prospect_id, data)
else:
raise ValueError("Either merchant_id or prospect_id is required")
# Check for duplicate subdomain
subdomain = slug
existing = db.query(Store).filter(Store.subdomain == subdomain).first()
if existing:
raise DuplicateSlugException(subdomain)
store_code = slug.upper().replace("-", "_")[:50]
store_data = StoreCreate(
merchant_id=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=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, merchant_id=%d)", site.display_name, store.id, merchant.id)
return site
def _create_merchant_from_prospect(self, db: Session, prospect_id: int, data: dict):
"""Create a merchant from prospect data."""
from app.modules.prospecting.models import Prospect
from app.modules.tenancy.schemas.merchant import MerchantCreate
from app.modules.tenancy.services.merchant_service import merchant_service
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 contact info: prefer form data, fall back to prospect contacts
contacts = prospect.contacts or []
email = (
data.get("contact_email")
or next((c.value for c in contacts if c.contact_type == "email"), None)
or f"contact-{prospect_id}@hostwizard.lu"
)
phone = data.get("contact_phone") or next(
(c.value for c in contacts if c.contact_type == "phone"), None
)
business_name = data.get("business_name") or prospect.business_name or prospect.domain_name
merchant_data = MerchantCreate(
name=business_name,
contact_email=email,
contact_phone=phone,
owner_email=email,
)
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
db, merchant_data
)
logger.info("Created merchant %s from prospect %d", merchant.name, prospect_id)
return merchant
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 subscription, mark prospect converted.
The merchant already exists (assigned at site creation time).
Optionally pass merchant_id to reassign to a different merchant.
"""
site = self._transition(db, site_id, HostedSiteStatus.ACCEPTED)
site.proposal_accepted_at = datetime.now(UTC)
from app.modules.tenancy.models import Merchant, Platform
# Use provided merchant_id to reassign, or keep existing store merchant
if merchant_id:
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise ValueError(f"Merchant {merchant_id} not found")
site.store.merchant_id = merchant.id
db.flush()
else:
merchant = site.store.merchant
# 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()
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()