fix(hosting): require merchant or prospect for site creation
- Schema: add merchant_id/prospect_id with model_validator requiring at least one. Remove from-prospect endpoint (unified into POST /sites) - Service: rewrite create() — if merchant_id use it directly, if prospect_id auto-create merchant from prospect data. Remove system merchant hack entirely. Extract _create_merchant_from_prospect helper. - Simplify accept_proposal() — merchant already exists at creation, only creates subscription and marks prospect converted - Tests: update all create calls with merchant_id, replace from-prospect tests with prospect_id + validation tests Closes docs/proposals/hosting-site-creation-fix.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -96,17 +96,6 @@ def create_site(
|
|||||||
return _to_response(site)
|
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)
|
@router.put("/{site_id}", response_model=HostedSiteResponse)
|
||||||
def update_site(
|
def update_site(
|
||||||
|
|||||||
@@ -3,18 +3,31 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class HostedSiteCreate(BaseModel):
|
class HostedSiteCreate(BaseModel):
|
||||||
"""Schema for creating a hosted site."""
|
"""Schema for creating a hosted site.
|
||||||
|
|
||||||
|
Either merchant_id or prospect_id must be provided:
|
||||||
|
- merchant_id: store is created under this merchant
|
||||||
|
- prospect_id: a merchant is auto-created from prospect data
|
||||||
|
"""
|
||||||
|
|
||||||
business_name: str = Field(..., max_length=255)
|
business_name: str = Field(..., max_length=255)
|
||||||
|
merchant_id: int | None = None
|
||||||
|
prospect_id: int | None = None
|
||||||
contact_name: str | None = Field(None, max_length=255)
|
contact_name: str | None = Field(None, max_length=255)
|
||||||
contact_email: str | None = Field(None, max_length=255)
|
contact_email: str | None = Field(None, max_length=255)
|
||||||
contact_phone: str | None = Field(None, max_length=50)
|
contact_phone: str | None = Field(None, max_length=50)
|
||||||
internal_notes: str | None = None
|
internal_notes: str | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_merchant_or_prospect(self) -> "HostedSiteCreate":
|
||||||
|
if not self.merchant_id and not self.prospect_id:
|
||||||
|
raise ValueError("Either merchant_id or prospect_id is required")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class HostedSiteUpdate(BaseModel):
|
class HostedSiteUpdate(BaseModel):
|
||||||
"""Schema for updating a hosted site."""
|
"""Schema for updating a hosted site."""
|
||||||
|
|||||||
@@ -88,12 +88,19 @@ class HostedSiteService:
|
|||||||
return sites, total
|
return sites, total
|
||||||
|
|
||||||
def create(self, db: Session, data: dict) -> HostedSite:
|
def create(self, db: Session, data: dict) -> HostedSite:
|
||||||
"""Create a hosted site with an auto-created Store on the hosting platform."""
|
"""Create a hosted site with an auto-created Store on the hosting platform.
|
||||||
from app.modules.tenancy.models import 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.schemas.store import StoreCreate
|
||||||
from app.modules.tenancy.services.admin_service import admin_service
|
from app.modules.tenancy.services.admin_service import admin_service
|
||||||
|
|
||||||
business_name = data["business_name"]
|
business_name = data["business_name"]
|
||||||
|
merchant_id = data.get("merchant_id")
|
||||||
|
prospect_id = data.get("prospect_id")
|
||||||
slug = _slugify(business_name)
|
slug = _slugify(business_name)
|
||||||
|
|
||||||
# Find hosting platform
|
# Find hosting platform
|
||||||
@@ -101,37 +108,25 @@ class HostedSiteService:
|
|||||||
if not platform:
|
if not platform:
|
||||||
raise ValueError("Hosting platform not found. Run init_production first.")
|
raise ValueError("Hosting platform not found. Run init_production first.")
|
||||||
|
|
||||||
# Create a temporary merchant-less store requires a merchant_id.
|
# Resolve merchant
|
||||||
# For POC sites we create a placeholder: the store is re-assigned on accept_proposal.
|
if merchant_id:
|
||||||
# Use the platform's own admin store or create under a system merchant.
|
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||||
# For now, create store via AdminService which handles defaults.
|
if not merchant:
|
||||||
store_code = slug.upper().replace("-", "_")[:50]
|
raise ValueError(f"Merchant {merchant_id} not found")
|
||||||
subdomain = slug
|
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
|
# Check for duplicate subdomain
|
||||||
from app.modules.tenancy.models import Store
|
subdomain = slug
|
||||||
|
|
||||||
existing = db.query(Store).filter(Store.subdomain == subdomain).first()
|
existing = db.query(Store).filter(Store.subdomain == subdomain).first()
|
||||||
if existing:
|
if existing:
|
||||||
raise DuplicateSlugException(subdomain)
|
raise DuplicateSlugException(subdomain)
|
||||||
|
|
||||||
# We need a system merchant for POC sites.
|
store_code = slug.upper().replace("-", "_")[:50]
|
||||||
# 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(
|
store_data = StoreCreate(
|
||||||
merchant_id=system_merchant.id,
|
merchant_id=merchant.id,
|
||||||
store_code=store_code,
|
store_code=store_code,
|
||||||
subdomain=subdomain,
|
subdomain=subdomain,
|
||||||
name=business_name,
|
name=business_name,
|
||||||
@@ -142,7 +137,7 @@ class HostedSiteService:
|
|||||||
|
|
||||||
site = HostedSite(
|
site = HostedSite(
|
||||||
store_id=store.id,
|
store_id=store.id,
|
||||||
prospect_id=data.get("prospect_id"),
|
prospect_id=prospect_id,
|
||||||
status=HostedSiteStatus.DRAFT,
|
status=HostedSiteStatus.DRAFT,
|
||||||
business_name=business_name,
|
business_name=business_name,
|
||||||
contact_name=data.get("contact_name"),
|
contact_name=data.get("contact_name"),
|
||||||
@@ -153,12 +148,14 @@ class HostedSiteService:
|
|||||||
db.add(site)
|
db.add(site)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
logger.info("Created hosted site: %s (store_id=%d)", site.display_name, store.id)
|
logger.info("Created hosted site: %s (store_id=%d, merchant_id=%d)", site.display_name, store.id, merchant.id)
|
||||||
return site
|
return site
|
||||||
|
|
||||||
def create_from_prospect(self, db: Session, prospect_id: int) -> HostedSite:
|
def _create_merchant_from_prospect(self, db: Session, prospect_id: int, data: dict):
|
||||||
"""Create a hosted site pre-filled from prospect data."""
|
"""Create a merchant from prospect data."""
|
||||||
from app.modules.prospecting.models import Prospect
|
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()
|
prospect = db.query(Prospect).filter(Prospect.id == prospect_id).first()
|
||||||
if not prospect:
|
if not prospect:
|
||||||
@@ -166,20 +163,29 @@ class HostedSiteService:
|
|||||||
|
|
||||||
raise ProspectNotFoundException(str(prospect_id))
|
raise ProspectNotFoundException(str(prospect_id))
|
||||||
|
|
||||||
# Get primary contact info from prospect contacts
|
# Get contact info: prefer form data, fall back to prospect contacts
|
||||||
contacts = prospect.contacts or []
|
contacts = prospect.contacts or []
|
||||||
primary_email = next((c.value for c in contacts if c.contact_type == "email"), None)
|
email = (
|
||||||
primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), None)
|
data.get("contact_email")
|
||||||
contact_name = next((c.label for c in contacts if c.label), None)
|
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
|
||||||
|
|
||||||
data = {
|
merchant_data = MerchantCreate(
|
||||||
"business_name": prospect.business_name or prospect.domain_name or f"Prospect #{prospect.id}",
|
name=business_name,
|
||||||
"contact_name": contact_name,
|
contact_email=email,
|
||||||
"contact_email": primary_email,
|
contact_phone=phone,
|
||||||
"contact_phone": primary_phone,
|
owner_email=email,
|
||||||
"prospect_id": prospect.id,
|
)
|
||||||
}
|
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
|
||||||
return self.create(db, data)
|
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:
|
def update(self, db: Session, site_id: int, data: dict) -> HostedSite:
|
||||||
site = self.get_by_id(db, site_id)
|
site = self.get_by_id(db, site_id)
|
||||||
@@ -227,37 +233,25 @@ class HostedSiteService:
|
|||||||
def accept_proposal(
|
def accept_proposal(
|
||||||
self, db: Session, site_id: int, merchant_id: int | None = None
|
self, db: Session, site_id: int, merchant_id: int | None = None
|
||||||
) -> HostedSite:
|
) -> HostedSite:
|
||||||
"""Accept proposal: create or link merchant, create subscription, mark converted."""
|
"""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 = self._transition(db, site_id, HostedSiteStatus.ACCEPTED)
|
||||||
site.proposal_accepted_at = datetime.now(UTC)
|
site.proposal_accepted_at = datetime.now(UTC)
|
||||||
|
|
||||||
from app.modules.tenancy.models import Merchant, Platform
|
from app.modules.tenancy.models import Merchant, Platform
|
||||||
|
|
||||||
|
# Use provided merchant_id to reassign, or keep existing store merchant
|
||||||
if merchant_id:
|
if merchant_id:
|
||||||
# Link to existing merchant
|
|
||||||
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||||
if not merchant:
|
if not merchant:
|
||||||
raise ValueError(f"Merchant {merchant_id} not found")
|
raise ValueError(f"Merchant {merchant_id} not found")
|
||||||
|
site.store.merchant_id = merchant.id
|
||||||
|
db.flush()
|
||||||
else:
|
else:
|
||||||
# Create new merchant from contact info
|
merchant = site.store.merchant
|
||||||
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
|
# Create MerchantSubscription on hosting platform
|
||||||
platform = db.query(Platform).filter(Platform.code == "hosting").first()
|
platform = db.query(Platform).filter(Platform.code == "hosting").first()
|
||||||
@@ -286,7 +280,6 @@ class HostedSiteService:
|
|||||||
prospect = db.query(Prospect).filter(Prospect.id == site.prospect_id).first()
|
prospect = db.query(Prospect).filter(Prospect.id == site.prospect_id).first()
|
||||||
if prospect and prospect.status != ProspectStatus.CONVERTED:
|
if prospect and prospect.status != ProspectStatus.CONVERTED:
|
||||||
prospect.status = ProspectStatus.CONVERTED
|
prospect.status = ProspectStatus.CONVERTED
|
||||||
db.flush()
|
|
||||||
|
|
||||||
db.flush()
|
db.flush()
|
||||||
logger.info("Proposal accepted for site %d (merchant=%d)", site_id, merchant.id)
|
logger.info("Proposal accepted for site %d (merchant=%d)", site_id, merchant.id)
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ def hosted_site(db, hosting_platform, system_merchant):
|
|||||||
db,
|
db,
|
||||||
{
|
{
|
||||||
"business_name": f"Test Business {unique}",
|
"business_name": f"Test Business {unique}",
|
||||||
|
"merchant_id": system_merchant.id,
|
||||||
"contact_name": "John Doe",
|
"contact_name": "John Doe",
|
||||||
"contact_email": f"john-{unique}@example.com",
|
"contact_email": f"john-{unique}@example.com",
|
||||||
"contact_phone": "+352 123 456",
|
"contact_phone": "+352 123 456",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class TestHostedSiteService:
|
|||||||
db,
|
db,
|
||||||
{
|
{
|
||||||
"business_name": f"New Business {unique}",
|
"business_name": f"New Business {unique}",
|
||||||
|
"merchant_id": system_merchant.id,
|
||||||
"contact_email": f"test-{unique}@example.com",
|
"contact_email": f"test-{unique}@example.com",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -72,6 +73,7 @@ class TestHostedSiteService:
|
|||||||
db,
|
db,
|
||||||
{
|
{
|
||||||
"business_name": f"Full Business {unique}",
|
"business_name": f"Full Business {unique}",
|
||||||
|
"merchant_id": system_merchant.id,
|
||||||
"contact_name": "Jane Doe",
|
"contact_name": "Jane Doe",
|
||||||
"contact_email": f"jane-{unique}@example.com",
|
"contact_email": f"jane-{unique}@example.com",
|
||||||
"contact_phone": "+352 999 888",
|
"contact_phone": "+352 999 888",
|
||||||
@@ -251,75 +253,65 @@ class TestHostedSiteLifecycle:
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.hosting
|
@pytest.mark.hosting
|
||||||
class TestHostedSiteFromProspect:
|
class TestHostedSiteFromProspect:
|
||||||
"""Tests for creating hosted sites from prospects."""
|
"""Tests for creating hosted sites from prospects via prospect_id."""
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
self.service = HostedSiteService()
|
self.service = HostedSiteService()
|
||||||
|
|
||||||
def test_create_from_prospect(self, db, hosting_platform, system_merchant):
|
def test_create_with_prospect_id(self, db, hosting_platform):
|
||||||
"""Test creating a hosted site from a prospect."""
|
"""Test creating a hosted site with prospect_id auto-creates merchant."""
|
||||||
from app.modules.prospecting.models import Prospect
|
from app.modules.prospecting.models import Prospect, ProspectContact
|
||||||
|
|
||||||
|
unique = uuid.uuid4().hex[:8]
|
||||||
prospect = Prospect(
|
prospect = Prospect(
|
||||||
channel="digital",
|
channel="digital",
|
||||||
domain_name=f"prospect-{uuid.uuid4().hex[:8]}.lu",
|
domain_name=f"prospect-{unique}.lu",
|
||||||
business_name="Prospect Business",
|
business_name="Prospect Business",
|
||||||
status="active",
|
status="active",
|
||||||
has_website=True,
|
has_website=True,
|
||||||
)
|
)
|
||||||
db.add(prospect)
|
db.add(prospect)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Add email contact (needed for merchant creation)
|
||||||
|
db.add(ProspectContact(
|
||||||
|
prospect_id=prospect.id,
|
||||||
|
contact_type="email",
|
||||||
|
value=f"hello-{unique}@test.lu",
|
||||||
|
is_primary=True,
|
||||||
|
))
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(prospect)
|
db.refresh(prospect)
|
||||||
|
|
||||||
site = self.service.create_from_prospect(db, prospect.id)
|
site = self.service.create(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
"business_name": "Prospect Business",
|
||||||
|
"prospect_id": prospect.id,
|
||||||
|
"contact_email": f"hello-{unique}@test.lu",
|
||||||
|
},
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
assert site.id is not None
|
assert site.id is not None
|
||||||
assert site.prospect_id == prospect.id
|
assert site.prospect_id == prospect.id
|
||||||
assert site.business_name == "Prospect Business"
|
assert site.business_name == "Prospect Business"
|
||||||
|
assert site.store.merchant is not None
|
||||||
|
|
||||||
def test_create_from_prospect_with_contacts(
|
def test_create_without_merchant_or_prospect_raises(self, db, hosting_platform):
|
||||||
self, db, hosting_platform, system_merchant
|
"""Test that creating without merchant_id or prospect_id raises ValueError."""
|
||||||
):
|
with pytest.raises(ValueError, match="Either merchant_id or prospect_id"):
|
||||||
"""Test creating from prospect pre-fills contact info."""
|
self.service.create(
|
||||||
from app.modules.prospecting.models import Prospect, ProspectContact
|
db,
|
||||||
|
{"business_name": "No Owner"},
|
||||||
|
)
|
||||||
|
|
||||||
prospect = Prospect(
|
def test_create_with_nonexistent_prospect_raises(self, db, hosting_platform):
|
||||||
channel="digital",
|
"""Test creating with non-existent prospect raises exception."""
|
||||||
domain_name=f"contacts-{uuid.uuid4().hex[:8]}.lu",
|
|
||||||
business_name="Contact Business",
|
|
||||||
status="active",
|
|
||||||
)
|
|
||||||
db.add(prospect)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
email_contact = ProspectContact(
|
|
||||||
prospect_id=prospect.id,
|
|
||||||
contact_type="email",
|
|
||||||
value="hello@test.lu",
|
|
||||||
is_primary=True,
|
|
||||||
)
|
|
||||||
phone_contact = ProspectContact(
|
|
||||||
prospect_id=prospect.id,
|
|
||||||
contact_type="phone",
|
|
||||||
value="+352 111 222",
|
|
||||||
is_primary=True,
|
|
||||||
)
|
|
||||||
db.add_all([email_contact, phone_contact])
|
|
||||||
db.commit()
|
|
||||||
db.refresh(prospect)
|
|
||||||
|
|
||||||
site = self.service.create_from_prospect(db, prospect.id)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
assert site.contact_email == "hello@test.lu"
|
|
||||||
assert site.contact_phone == "+352 111 222"
|
|
||||||
|
|
||||||
def test_create_from_nonexistent_prospect(
|
|
||||||
self, db, hosting_platform, system_merchant
|
|
||||||
):
|
|
||||||
"""Test creating from non-existent prospect raises exception."""
|
|
||||||
from app.modules.prospecting.exceptions import ProspectNotFoundException
|
from app.modules.prospecting.exceptions import ProspectNotFoundException
|
||||||
|
|
||||||
with pytest.raises(ProspectNotFoundException):
|
with pytest.raises(ProspectNotFoundException):
|
||||||
self.service.create_from_prospect(db, 99999)
|
self.service.create(
|
||||||
|
db,
|
||||||
|
{"business_name": "Ghost", "prospect_id": 99999},
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user