feat: module-driven onboarding system + simplified 3-step signup
Add OnboardingProviderProtocol so modules declare their own post-signup onboarding steps. The core OnboardingAggregator discovers enabled providers and exposes a dashboard API (GET /dashboard/onboarding). A session-scoped banner on the store dashboard shows a checklist that guides merchants through setup without blocking signup. Signup is simplified from 4 steps to 3 (Plan → Account → Payment): store creation is merged into account creation, store language is captured from the user's browsing language, and platform-specific template branching is removed. Includes 47 unit and integration tests covering all new providers, the aggregator, the API endpoint, and the signup service changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,15 @@ def _get_feature_provider():
|
||||
return marketplace_feature_provider
|
||||
|
||||
|
||||
def _get_onboarding_provider():
|
||||
"""Lazy import of onboarding provider to avoid circular imports."""
|
||||
from app.modules.marketplace.services.marketplace_onboarding import (
|
||||
marketplace_onboarding_provider,
|
||||
)
|
||||
|
||||
return marketplace_onboarding_provider
|
||||
|
||||
|
||||
# Marketplace module definition
|
||||
marketplace_module = ModuleDefinition(
|
||||
code="marketplace",
|
||||
@@ -186,6 +195,8 @@ marketplace_module = ModuleDefinition(
|
||||
widget_provider=_get_widget_provider,
|
||||
# Feature provider for feature flags
|
||||
feature_provider=_get_feature_provider,
|
||||
# Onboarding provider for post-signup checklist
|
||||
onboarding_provider=_get_onboarding_provider,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -95,15 +95,7 @@ async def store_marketplace_page(
|
||||
"""
|
||||
Render marketplace import page.
|
||||
JavaScript loads import jobs and products via API.
|
||||
Redirects to onboarding if not completed.
|
||||
"""
|
||||
onboarding_service = OnboardingService(db)
|
||||
if not onboarding_service.is_completed(current_user.token_store_id):
|
||||
return RedirectResponse(
|
||||
url=f"/store/{store_code}/onboarding",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"marketplace/store/marketplace.html",
|
||||
get_store_context(request, db, current_user, store_code),
|
||||
@@ -127,15 +119,7 @@ async def store_letzshop_page(
|
||||
"""
|
||||
Render Letzshop integration page.
|
||||
JavaScript loads orders, credentials status, and handles fulfillment operations.
|
||||
Redirects to onboarding if not completed.
|
||||
"""
|
||||
onboarding_service = OnboardingService(db)
|
||||
if not onboarding_service.is_completed(current_user.token_store_id):
|
||||
return RedirectResponse(
|
||||
url=f"/store/{store_code}/onboarding",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"marketplace/store/letzshop.html",
|
||||
get_store_context(request, db, current_user, store_code),
|
||||
|
||||
79
app/modules/marketplace/services/marketplace_onboarding.py
Normal file
79
app/modules/marketplace/services/marketplace_onboarding.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# app/modules/marketplace/services/marketplace_onboarding.py
|
||||
"""
|
||||
Onboarding provider for the marketplace module.
|
||||
|
||||
Provides two onboarding steps:
|
||||
1. Connect Letzshop API - completed when StoreLetzshopCredentials exists with api_key
|
||||
2. Import products - completed when at least 1 product exists in catalog
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.contracts.onboarding import OnboardingStepDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarketplaceOnboardingProvider:
|
||||
"""Onboarding provider for marketplace module."""
|
||||
|
||||
@property
|
||||
def onboarding_category(self) -> str:
|
||||
return "marketplace"
|
||||
|
||||
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
|
||||
return [
|
||||
OnboardingStepDefinition(
|
||||
key="marketplace.connect_api",
|
||||
title_key="onboarding.marketplace.connect_api.title",
|
||||
description_key="onboarding.marketplace.connect_api.description",
|
||||
icon="plug",
|
||||
route_template="/store/{store_code}/letzshop",
|
||||
order=200,
|
||||
category="marketplace",
|
||||
),
|
||||
OnboardingStepDefinition(
|
||||
key="marketplace.import_products",
|
||||
title_key="onboarding.marketplace.import_products.title",
|
||||
description_key="onboarding.marketplace.import_products.description",
|
||||
icon="package",
|
||||
route_template="/store/{store_code}/marketplace",
|
||||
order=210,
|
||||
category="marketplace",
|
||||
),
|
||||
]
|
||||
|
||||
def is_step_completed(
|
||||
self, db: Session, store_id: int, step_key: str
|
||||
) -> bool:
|
||||
if step_key == "marketplace.connect_api":
|
||||
return self._has_letzshop_credentials(db, store_id)
|
||||
if step_key == "marketplace.import_products":
|
||||
return self._has_products(db, store_id)
|
||||
return False
|
||||
|
||||
def _has_letzshop_credentials(self, db: Session, store_id: int) -> bool:
|
||||
from app.modules.marketplace.models.letzshop import StoreLetzshopCredentials
|
||||
|
||||
creds = (
|
||||
db.query(StoreLetzshopCredentials)
|
||||
.filter(StoreLetzshopCredentials.store_id == store_id)
|
||||
.first()
|
||||
)
|
||||
return bool(creds and creds.api_key_encrypted)
|
||||
|
||||
def _has_products(self, db: Session, store_id: int) -> bool:
|
||||
from app.modules.catalog.models.product import Product
|
||||
|
||||
count = (
|
||||
db.query(Product)
|
||||
.filter(Product.store_id == store_id)
|
||||
.limit(1)
|
||||
.count()
|
||||
)
|
||||
return count > 0
|
||||
|
||||
|
||||
marketplace_onboarding_provider = MarketplaceOnboardingProvider()
|
||||
@@ -0,0 +1,140 @@
|
||||
# app/modules/marketplace/tests/unit/test_marketplace_onboarding.py
|
||||
"""Unit tests for MarketplaceOnboardingProvider."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.marketplace.services.marketplace_onboarding import (
|
||||
MarketplaceOnboardingProvider,
|
||||
)
|
||||
from app.modules.tenancy.models import Merchant, Store, User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp_owner(db):
|
||||
"""Create a store owner for marketplace onboarding tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
user = User(
|
||||
email=f"mpowner_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"mpowner_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp_merchant(db, mp_owner):
|
||||
"""Create a merchant for marketplace onboarding tests."""
|
||||
merchant = Merchant(
|
||||
name="MP Onboarding Merchant",
|
||||
owner_user_id=mp_owner.id,
|
||||
contact_email=mp_owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp_store(db, mp_merchant):
|
||||
"""Create a store for marketplace onboarding tests."""
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
store = Store(
|
||||
merchant_id=mp_merchant.id,
|
||||
store_code=f"MPSTORE_{uid.upper()}",
|
||||
subdomain=f"mpstore{uid.lower()}",
|
||||
name="MP Test Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.marketplace
|
||||
class TestMarketplaceOnboardingProvider:
|
||||
"""Tests for MarketplaceOnboardingProvider."""
|
||||
|
||||
def setup_method(self):
|
||||
self.provider = MarketplaceOnboardingProvider()
|
||||
|
||||
def test_category(self):
|
||||
"""Returns 'marketplace' as the onboarding category."""
|
||||
assert self.provider.onboarding_category == "marketplace"
|
||||
|
||||
def test_get_onboarding_steps_returns_two_steps(self):
|
||||
"""Returns two steps: connect_api and import_products."""
|
||||
steps = self.provider.get_onboarding_steps()
|
||||
assert len(steps) == 2
|
||||
assert steps[0].key == "marketplace.connect_api"
|
||||
assert steps[0].order == 200
|
||||
assert steps[1].key == "marketplace.import_products"
|
||||
assert steps[1].order == 210
|
||||
|
||||
def test_connect_api_incomplete_without_credentials(self, db, mp_store):
|
||||
"""connect_api step is incomplete when no credentials exist."""
|
||||
result = self.provider.is_step_completed(
|
||||
db, mp_store.id, "marketplace.connect_api"
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_connect_api_complete_with_credentials(self, db, mp_store):
|
||||
"""connect_api step is complete when StoreLetzshopCredentials has api_key."""
|
||||
from app.modules.marketplace.models.letzshop import StoreLetzshopCredentials
|
||||
|
||||
creds = StoreLetzshopCredentials(
|
||||
store_id=mp_store.id,
|
||||
api_key_encrypted="encrypted_key_here",
|
||||
)
|
||||
db.add(creds)
|
||||
db.commit()
|
||||
|
||||
result = self.provider.is_step_completed(
|
||||
db, mp_store.id, "marketplace.connect_api"
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_import_products_incomplete_without_products(self, db, mp_store):
|
||||
"""import_products step is incomplete when store has no products."""
|
||||
result = self.provider.is_step_completed(
|
||||
db, mp_store.id, "marketplace.import_products"
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_import_products_complete_with_products(self, db, mp_store):
|
||||
"""import_products step is complete when store has at least one product."""
|
||||
from app.modules.catalog.models.product import Product
|
||||
|
||||
product = Product(
|
||||
store_id=mp_store.id,
|
||||
store_sku=f"SKU-{uuid.uuid4().hex[:8]}",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
|
||||
result = self.provider.is_step_completed(
|
||||
db, mp_store.id, "marketplace.import_products"
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_unknown_step_key_returns_false(self, db, mp_store):
|
||||
"""Returns False for unrecognized step key."""
|
||||
result = self.provider.is_step_completed(
|
||||
db, mp_store.id, "marketplace.unknown"
|
||||
)
|
||||
assert result is False
|
||||
Reference in New Issue
Block a user