diff --git a/app/modules/hosting/routes/api/admin_sites.py b/app/modules/hosting/routes/api/admin_sites.py index e08ba1fa..d5d1e2ce 100644 --- a/app/modules/hosting/routes/api/admin_sites.py +++ b/app/modules/hosting/routes/api/admin_sites.py @@ -96,17 +96,6 @@ def create_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) def update_site( diff --git a/app/modules/hosting/schemas/hosted_site.py b/app/modules/hosting/schemas/hosted_site.py index e81aafc3..40992442 100644 --- a/app/modules/hosting/schemas/hosted_site.py +++ b/app/modules/hosting/schemas/hosted_site.py @@ -3,18 +3,31 @@ from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator 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) + merchant_id: int | None = None + prospect_id: int | None = None contact_name: 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) 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): """Schema for updating a hosted site.""" diff --git a/app/modules/hosting/services/hosted_site_service.py b/app/modules/hosting/services/hosted_site_service.py index f5fea879..10450bc4 100644 --- a/app/modules/hosting/services/hosted_site_service.py +++ b/app/modules/hosting/services/hosted_site_service.py @@ -88,12 +88,19 @@ class HostedSiteService: 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 + """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") slug = _slugify(business_name) # Find hosting platform @@ -101,37 +108,25 @@ class HostedSiteService: 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 + # 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 - from app.modules.tenancy.models import Store - + subdomain = slug 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_code = slug.upper().replace("-", "_")[:50] store_data = StoreCreate( - merchant_id=system_merchant.id, + merchant_id=merchant.id, store_code=store_code, subdomain=subdomain, name=business_name, @@ -142,7 +137,7 @@ class HostedSiteService: site = HostedSite( store_id=store.id, - prospect_id=data.get("prospect_id"), + prospect_id=prospect_id, status=HostedSiteStatus.DRAFT, business_name=business_name, contact_name=data.get("contact_name"), @@ -153,12 +148,14 @@ class HostedSiteService: db.add(site) 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 - def create_from_prospect(self, db: Session, prospect_id: int) -> HostedSite: - """Create a hosted site pre-filled from prospect data.""" + 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: @@ -166,20 +163,29 @@ class HostedSiteService: 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 [] - 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) + 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 - 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) + 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) @@ -227,37 +233,25 @@ class HostedSiteService: 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.""" + """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: - # 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") + site.store.merchant_id = merchant.id + db.flush() 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() + merchant = site.store.merchant # Create MerchantSubscription on hosting platform 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() 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) diff --git a/app/modules/hosting/tests/conftest.py b/app/modules/hosting/tests/conftest.py index 49342d31..104c6fbf 100644 --- a/app/modules/hosting/tests/conftest.py +++ b/app/modules/hosting/tests/conftest.py @@ -89,6 +89,7 @@ def hosted_site(db, hosting_platform, system_merchant): db, { "business_name": f"Test Business {unique}", + "merchant_id": system_merchant.id, "contact_name": "John Doe", "contact_email": f"john-{unique}@example.com", "contact_phone": "+352 123 456", diff --git a/app/modules/hosting/tests/unit/test_hosted_site_service.py b/app/modules/hosting/tests/unit/test_hosted_site_service.py index 58d74da1..2150c602 100644 --- a/app/modules/hosting/tests/unit/test_hosted_site_service.py +++ b/app/modules/hosting/tests/unit/test_hosted_site_service.py @@ -55,6 +55,7 @@ class TestHostedSiteService: db, { "business_name": f"New Business {unique}", + "merchant_id": system_merchant.id, "contact_email": f"test-{unique}@example.com", }, ) @@ -72,6 +73,7 @@ class TestHostedSiteService: db, { "business_name": f"Full Business {unique}", + "merchant_id": system_merchant.id, "contact_name": "Jane Doe", "contact_email": f"jane-{unique}@example.com", "contact_phone": "+352 999 888", @@ -251,75 +253,65 @@ class TestHostedSiteLifecycle: @pytest.mark.unit @pytest.mark.hosting class TestHostedSiteFromProspect: - """Tests for creating hosted sites from prospects.""" + """Tests for creating hosted sites from prospects via prospect_id.""" def setup_method(self): self.service = HostedSiteService() - def test_create_from_prospect(self, db, hosting_platform, system_merchant): - """Test creating a hosted site from a prospect.""" - from app.modules.prospecting.models import Prospect + def test_create_with_prospect_id(self, db, hosting_platform): + """Test creating a hosted site with prospect_id auto-creates merchant.""" + from app.modules.prospecting.models import Prospect, ProspectContact + unique = uuid.uuid4().hex[:8] prospect = Prospect( channel="digital", - domain_name=f"prospect-{uuid.uuid4().hex[:8]}.lu", + domain_name=f"prospect-{unique}.lu", business_name="Prospect Business", status="active", has_website=True, ) 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.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() assert site.id is not None assert site.prospect_id == prospect.id assert site.business_name == "Prospect Business" + assert site.store.merchant is not None - def test_create_from_prospect_with_contacts( - self, db, hosting_platform, system_merchant - ): - """Test creating from prospect pre-fills contact info.""" - from app.modules.prospecting.models import Prospect, ProspectContact + def test_create_without_merchant_or_prospect_raises(self, db, hosting_platform): + """Test that creating without merchant_id or prospect_id raises ValueError.""" + with pytest.raises(ValueError, match="Either merchant_id or prospect_id"): + self.service.create( + db, + {"business_name": "No Owner"}, + ) - prospect = Prospect( - channel="digital", - 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.""" + def test_create_with_nonexistent_prospect_raises(self, db, hosting_platform): + """Test creating with non-existent prospect raises exception.""" from app.modules.prospecting.exceptions import ProspectNotFoundException with pytest.raises(ProspectNotFoundException): - self.service.create_from_prospect(db, 99999) + self.service.create( + db, + {"business_name": "Ghost", "prospect_id": 99999}, + )