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:
@@ -36,6 +36,15 @@ def _get_feature_provider():
|
||||
return tenancy_feature_provider
|
||||
|
||||
|
||||
def _get_onboarding_provider():
|
||||
"""Lazy import of onboarding provider to avoid circular imports."""
|
||||
from app.modules.tenancy.services.tenancy_onboarding import (
|
||||
tenancy_onboarding_provider,
|
||||
)
|
||||
|
||||
return tenancy_onboarding_provider
|
||||
|
||||
|
||||
tenancy_module = ModuleDefinition(
|
||||
code="tenancy",
|
||||
name="Tenancy Management",
|
||||
@@ -233,6 +242,7 @@ tenancy_module = ModuleDefinition(
|
||||
# Widget provider for dashboard widgets
|
||||
widget_provider=_get_widget_provider,
|
||||
feature_provider=_get_feature_provider,
|
||||
onboarding_provider=_get_onboarding_provider,
|
||||
)
|
||||
|
||||
__all__ = ["tenancy_module"]
|
||||
|
||||
55
app/modules/tenancy/services/tenancy_onboarding.py
Normal file
55
app/modules/tenancy/services/tenancy_onboarding.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# app/modules/tenancy/services/tenancy_onboarding.py
|
||||
"""
|
||||
Onboarding provider for the tenancy module.
|
||||
|
||||
Provides the "Customize your store" onboarding step that is always present
|
||||
for all platforms. Completed when the store has a description or a logo.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.contracts.onboarding import OnboardingStepDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TenancyOnboardingProvider:
|
||||
"""Onboarding provider for tenancy module (always present)."""
|
||||
|
||||
@property
|
||||
def onboarding_category(self) -> str:
|
||||
return "tenancy"
|
||||
|
||||
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
|
||||
return [
|
||||
OnboardingStepDefinition(
|
||||
key="tenancy.customize_store",
|
||||
title_key="onboarding.tenancy.customize_store.title",
|
||||
description_key="onboarding.tenancy.customize_store.description",
|
||||
icon="settings",
|
||||
route_template="/store/{store_code}/settings",
|
||||
order=100,
|
||||
category="tenancy",
|
||||
),
|
||||
]
|
||||
|
||||
def is_step_completed(
|
||||
self, db: Session, store_id: int, step_key: str
|
||||
) -> bool:
|
||||
if step_key != "tenancy.customize_store":
|
||||
return False
|
||||
|
||||
from app.modules.tenancy.models.store import Store
|
||||
|
||||
store = db.query(Store).filter(Store.id == store_id).first()
|
||||
if not store:
|
||||
return False
|
||||
|
||||
has_description = bool(store.description and store.description.strip())
|
||||
has_logo = bool(store.get_logo_url())
|
||||
return has_description or has_logo
|
||||
|
||||
|
||||
tenancy_onboarding_provider = TenancyOnboardingProvider()
|
||||
115
app/modules/tenancy/tests/unit/test_tenancy_onboarding.py
Normal file
115
app/modules/tenancy/tests/unit/test_tenancy_onboarding.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# app/modules/tenancy/tests/unit/test_tenancy_onboarding.py
|
||||
"""Unit tests for TenancyOnboardingProvider."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.models import Merchant, Store, User
|
||||
from app.modules.tenancy.services.tenancy_onboarding import TenancyOnboardingProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def onb_owner(db):
|
||||
"""Create a store owner for onboarding tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
user = User(
|
||||
email=f"onbowner_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"onbowner_{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 onb_merchant(db, onb_owner):
|
||||
"""Create a merchant for onboarding tests."""
|
||||
merchant = Merchant(
|
||||
name="Onboarding Test Merchant",
|
||||
owner_user_id=onb_owner.id,
|
||||
contact_email=onb_owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def onb_store(db, onb_merchant):
|
||||
"""Create a store with no description and no logo."""
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
store = Store(
|
||||
merchant_id=onb_merchant.id,
|
||||
store_code=f"ONBSTORE_{uid.upper()}",
|
||||
subdomain=f"onbstore{uid.lower()}",
|
||||
name="Onboarding Test Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tenancy
|
||||
class TestTenancyOnboardingProvider:
|
||||
"""Tests for TenancyOnboardingProvider."""
|
||||
|
||||
def setup_method(self):
|
||||
self.provider = TenancyOnboardingProvider()
|
||||
|
||||
def test_category(self):
|
||||
"""Returns 'tenancy' as the onboarding category."""
|
||||
assert self.provider.onboarding_category == "tenancy"
|
||||
|
||||
def test_get_onboarding_steps_returns_one_step(self):
|
||||
"""Returns exactly one step: customize_store."""
|
||||
steps = self.provider.get_onboarding_steps()
|
||||
assert len(steps) == 1
|
||||
assert steps[0].key == "tenancy.customize_store"
|
||||
assert steps[0].route_template == "/store/{store_code}/settings"
|
||||
assert steps[0].order == 100
|
||||
|
||||
def test_incomplete_when_no_description_no_logo(self, db, onb_store):
|
||||
"""Step is not completed when store has no description and no logo."""
|
||||
assert onb_store.description is None or onb_store.description.strip() == ""
|
||||
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.customize_store")
|
||||
assert result is False
|
||||
|
||||
def test_completed_when_store_has_description(self, db, onb_store):
|
||||
"""Step is completed when store has a non-empty description."""
|
||||
onb_store.description = "We sell great coffee!"
|
||||
db.commit()
|
||||
|
||||
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.customize_store")
|
||||
assert result is True
|
||||
|
||||
def test_incomplete_when_description_is_whitespace(self, db, onb_store):
|
||||
"""Step is not completed when description is whitespace only."""
|
||||
onb_store.description = " "
|
||||
db.commit()
|
||||
|
||||
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.customize_store")
|
||||
assert result is False
|
||||
|
||||
def test_incomplete_for_nonexistent_store(self, db):
|
||||
"""Returns False for a store ID that doesn't exist."""
|
||||
result = self.provider.is_step_completed(db, 999999, "tenancy.customize_store")
|
||||
assert result is False
|
||||
|
||||
def test_incomplete_for_unknown_step_key(self, db, onb_store):
|
||||
"""Returns False for an unrecognized step key."""
|
||||
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.unknown_step")
|
||||
assert result is False
|
||||
Reference in New Issue
Block a user