feat: add mandatory vendor onboarding wizard

Implement 4-step onboarding flow for new vendors after signup:
- Step 1: Company profile setup
- Step 2: Letzshop API configuration with connection testing
- Step 3: Product & order import CSV URL configuration
- Step 4: Historical order sync with progress bar

Key features:
- Blocks dashboard access until completed
- Step indicators with visual progress
- Resume capability (progress persisted in DB)
- Admin skip capability for support cases

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-27 21:46:26 +01:00
parent 64fd8b5194
commit 409a2eaa05
15 changed files with 2549 additions and 2 deletions

View File

@@ -47,6 +47,7 @@ from .marketplace_product import (
ProductType,
)
from .marketplace_product_translation import MarketplaceProductTranslation
from .onboarding import OnboardingStatus, OnboardingStep, VendorOnboarding
from .order import Order, OrderItem
from .order_item_exception import OrderItemException
from .product import Product
@@ -153,4 +154,8 @@ __all__ = [
"Message",
"MessageAttachment",
"ParticipantType",
# Onboarding
"OnboardingStatus",
"OnboardingStep",
"VendorOnboarding",
]

View File

@@ -0,0 +1,222 @@
# models/database/onboarding.py
"""
Vendor onboarding progress tracking.
Tracks completion status of mandatory onboarding steps for new vendors.
Onboarding must be completed before accessing the vendor dashboard.
"""
import enum
from datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
from .base import TimestampMixin
class OnboardingStep(str, enum.Enum):
"""Onboarding step identifiers."""
COMPANY_PROFILE = "company_profile"
LETZSHOP_API = "letzshop_api"
PRODUCT_IMPORT = "product_import"
ORDER_SYNC = "order_sync"
class OnboardingStatus(str, enum.Enum):
"""Overall onboarding status."""
NOT_STARTED = "not_started"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
SKIPPED = "skipped" # For admin override capability
# Step order for validation
STEP_ORDER = [
OnboardingStep.COMPANY_PROFILE,
OnboardingStep.LETZSHOP_API,
OnboardingStep.PRODUCT_IMPORT,
OnboardingStep.ORDER_SYNC,
]
class VendorOnboarding(Base, TimestampMixin):
"""
Per-vendor onboarding progress tracking.
Created automatically when vendor is created during signup.
Blocks dashboard access until status = 'completed' or skipped_by_admin = True.
"""
__tablename__ = "vendor_onboarding"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(
Integer,
ForeignKey("vendors.id", ondelete="CASCADE"),
unique=True,
nullable=False,
index=True,
)
# Overall status
status = Column(
String(20),
default=OnboardingStatus.NOT_STARTED.value,
nullable=False,
index=True,
)
current_step = Column(
String(30),
default=OnboardingStep.COMPANY_PROFILE.value,
nullable=False,
)
# Step 1: Company Profile
step_company_profile_completed = Column(Boolean, default=False, nullable=False)
step_company_profile_completed_at = Column(DateTime(timezone=True), nullable=True)
step_company_profile_data = Column(JSON, nullable=True) # Store what was entered
# Step 2: Letzshop API Configuration
step_letzshop_api_completed = Column(Boolean, default=False, nullable=False)
step_letzshop_api_completed_at = Column(DateTime(timezone=True), nullable=True)
step_letzshop_api_connection_verified = Column(Boolean, default=False, nullable=False)
# Step 3: Product & Order Import (CSV feed URL + historical import)
step_product_import_completed = Column(Boolean, default=False, nullable=False)
step_product_import_completed_at = Column(DateTime(timezone=True), nullable=True)
step_product_import_csv_url_set = Column(Boolean, default=False, nullable=False)
# Step 4: Order Sync
step_order_sync_completed = Column(Boolean, default=False, nullable=False)
step_order_sync_completed_at = Column(DateTime(timezone=True), nullable=True)
step_order_sync_job_id = Column(Integer, nullable=True) # FK to LetzshopHistoricalImportJob
# Completion tracking
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
# Admin override (for support cases)
skipped_by_admin = Column(Boolean, default=False, nullable=False)
skipped_at = Column(DateTime(timezone=True), nullable=True)
skipped_reason = Column(Text, nullable=True)
skipped_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# Relationships
vendor = relationship("Vendor", back_populates="onboarding")
__table_args__ = (
Index("idx_onboarding_vendor_status", "vendor_id", "status"),
{"sqlite_autoincrement": True},
)
def __repr__(self):
return f"<VendorOnboarding(vendor_id={self.vendor_id}, status='{self.status}', step='{self.current_step}')>"
@property
def is_completed(self) -> bool:
"""Check if onboarding is fully completed or skipped."""
return (
self.status == OnboardingStatus.COMPLETED.value or self.skipped_by_admin
)
@property
def completion_percentage(self) -> int:
"""Calculate completion percentage (0-100)."""
completed_steps = sum(
[
self.step_company_profile_completed,
self.step_letzshop_api_completed,
self.step_product_import_completed,
self.step_order_sync_completed,
]
)
return int((completed_steps / 4) * 100)
@property
def completed_steps_count(self) -> int:
"""Get number of completed steps."""
return sum(
[
self.step_company_profile_completed,
self.step_letzshop_api_completed,
self.step_product_import_completed,
self.step_order_sync_completed,
]
)
def is_step_completed(self, step: str) -> bool:
"""Check if a specific step is completed."""
step_mapping = {
OnboardingStep.COMPANY_PROFILE.value: self.step_company_profile_completed,
OnboardingStep.LETZSHOP_API.value: self.step_letzshop_api_completed,
OnboardingStep.PRODUCT_IMPORT.value: self.step_product_import_completed,
OnboardingStep.ORDER_SYNC.value: self.step_order_sync_completed,
}
return step_mapping.get(step, False)
def can_proceed_to_step(self, step: str) -> bool:
"""Check if user can proceed to a specific step (no skipping)."""
target_index = None
for i, s in enumerate(STEP_ORDER):
if s.value == step:
target_index = i
break
if target_index is None:
return False
# Check all previous steps are completed
for i in range(target_index):
if not self.is_step_completed(STEP_ORDER[i].value):
return False
return True
def get_next_step(self) -> str | None:
"""Get the next incomplete step."""
for step in STEP_ORDER:
if not self.is_step_completed(step.value):
return step.value
return None
def mark_step_complete(self, step: str, timestamp: datetime | None = None) -> None:
"""Mark a step as complete and update current step."""
if timestamp is None:
timestamp = datetime.utcnow()
if step == OnboardingStep.COMPANY_PROFILE.value:
self.step_company_profile_completed = True
self.step_company_profile_completed_at = timestamp
elif step == OnboardingStep.LETZSHOP_API.value:
self.step_letzshop_api_completed = True
self.step_letzshop_api_completed_at = timestamp
elif step == OnboardingStep.PRODUCT_IMPORT.value:
self.step_product_import_completed = True
self.step_product_import_completed_at = timestamp
elif step == OnboardingStep.ORDER_SYNC.value:
self.step_order_sync_completed = True
self.step_order_sync_completed_at = timestamp
# Update current step to next incomplete step
next_step = self.get_next_step()
if next_step:
self.current_step = next_step
else:
# All steps complete
self.status = OnboardingStatus.COMPLETED.value
self.completed_at = timestamp

View File

@@ -211,6 +211,14 @@ class Vendor(Base, TimestampMixin):
"ContentPage", back_populates="vendor", cascade="all, delete-orphan"
) # Relationship with ContentPage model for vendor-specific content pages
# Onboarding progress (one-to-one)
onboarding = relationship(
"VendorOnboarding",
back_populates="vendor",
uselist=False,
cascade="all, delete-orphan",
)
def __repr__(self):
"""String representation of the Vendor object."""
return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>"

View File

@@ -10,6 +10,7 @@ from . import (
marketplace_import_job,
marketplace_product,
message,
onboarding,
stats,
vendor,
)
@@ -24,6 +25,7 @@ __all__ = [
"marketplace_product",
"message",
"inventory",
"onboarding",
"vendor",
"marketplace_import_job",
"stats",

291
models/schema/onboarding.py Normal file
View File

@@ -0,0 +1,291 @@
# models/schema/onboarding.py
"""
Pydantic schemas for Vendor Onboarding operations.
Schemas include:
- OnboardingStatusResponse: Current onboarding status with all step states
- CompanyProfileRequest/Response: Step 1 - Company profile data
- LetzshopApiConfigRequest/Response: Step 2 - API configuration
- ProductImportConfigRequest/Response: Step 3 - CSV URL configuration
- OrderSyncTriggerResponse: Step 4 - Job trigger response
- OrderSyncProgressResponse: Step 4 - Progress polling response
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
# =============================================================================
# STEP STATUS MODELS
# =============================================================================
class StepStatus(BaseModel):
"""Status for a single onboarding step."""
completed: bool = False
completed_at: datetime | None = None
class CompanyProfileStepStatus(StepStatus):
"""Step 1 status with saved data."""
data: dict | None = None
class LetzshopApiStepStatus(StepStatus):
"""Step 2 status with connection verification."""
connection_verified: bool = False
class ProductImportStepStatus(StepStatus):
"""Step 3 status with CSV URL flag."""
csv_url_set: bool = False
class OrderSyncStepStatus(StepStatus):
"""Step 4 status with job tracking."""
job_id: int | None = None
# =============================================================================
# ONBOARDING STATUS RESPONSE
# =============================================================================
class OnboardingStatusResponse(BaseModel):
"""Full onboarding status with all step information."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
status: str # not_started, in_progress, completed, skipped
current_step: str # company_profile, letzshop_api, product_import, order_sync
# Step statuses
company_profile: CompanyProfileStepStatus
letzshop_api: LetzshopApiStepStatus
product_import: ProductImportStepStatus
order_sync: OrderSyncStepStatus
# Progress tracking
completion_percentage: int
completed_steps_count: int
total_steps: int = 4
# Completion info
is_completed: bool
started_at: datetime | None = None
completed_at: datetime | None = None
# Admin override info
skipped_by_admin: bool = False
skipped_at: datetime | None = None
skipped_reason: str | None = None
# =============================================================================
# STEP 1: COMPANY PROFILE
# =============================================================================
class CompanyProfileRequest(BaseModel):
"""Request to save company profile during onboarding Step 1."""
# Company name is already set during signup, but can be updated
company_name: str | None = Field(None, min_length=2, max_length=255)
# Vendor/brand name
brand_name: str | None = Field(None, min_length=2, max_length=255)
description: str | None = Field(None, max_length=2000)
# Contact information
contact_email: str | None = Field(None, max_length=255)
contact_phone: str | None = Field(None, max_length=50)
website: str | None = Field(None, max_length=255)
business_address: str | None = Field(None, max_length=500)
tax_number: str | None = Field(None, max_length=100)
# Language preferences
default_language: str = Field("fr", pattern="^(en|fr|de|lb)$")
dashboard_language: str = Field("fr", pattern="^(en|fr|de|lb)$")
class CompanyProfileResponse(BaseModel):
"""Response after saving company profile."""
success: bool
step_completed: bool
next_step: str | None = None
message: str | None = None
# =============================================================================
# STEP 2: LETZSHOP API CONFIGURATION
# =============================================================================
class LetzshopApiConfigRequest(BaseModel):
"""Request to configure Letzshop API credentials."""
api_key: str = Field(..., min_length=10, description="Letzshop API key")
shop_slug: str = Field(
...,
min_length=2,
max_length=100,
description="Letzshop shop URL slug (e.g., 'my-shop')",
)
vendor_id: str | None = Field(
None,
max_length=100,
description="Letzshop vendor ID (optional, auto-detected if not provided)",
)
class LetzshopApiTestRequest(BaseModel):
"""Request to test Letzshop API connection."""
api_key: str = Field(..., min_length=10)
shop_slug: str = Field(..., min_length=2, max_length=100)
class LetzshopApiTestResponse(BaseModel):
"""Response from Letzshop API connection test."""
success: bool
message: str
vendor_name: str | None = None
vendor_id: str | None = None
shop_slug: str | None = None
class LetzshopApiConfigResponse(BaseModel):
"""Response after saving Letzshop API configuration."""
success: bool
step_completed: bool
next_step: str | None = None
message: str | None = None
connection_verified: bool = False
# =============================================================================
# STEP 3: PRODUCT & ORDER IMPORT CONFIGURATION
# =============================================================================
class ProductImportConfigRequest(BaseModel):
"""Request to configure product import settings."""
# CSV feed URLs for each language
csv_url_fr: str | None = Field(None, max_length=500)
csv_url_en: str | None = Field(None, max_length=500)
csv_url_de: str | None = Field(None, max_length=500)
# Letzshop feed settings
default_tax_rate: int = Field(
17,
ge=0,
le=17,
description="Default VAT rate: 0, 3, 8, 14, or 17",
)
delivery_method: str = Field(
"package_delivery",
description="Delivery method: nationwide, package_delivery, self_collect",
)
preorder_days: int = Field(1, ge=0, le=30)
class ProductImportConfigResponse(BaseModel):
"""Response after saving product import configuration."""
success: bool
step_completed: bool
next_step: str | None = None
message: str | None = None
csv_urls_configured: int = 0
# =============================================================================
# STEP 4: ORDER SYNC
# =============================================================================
class OrderSyncTriggerRequest(BaseModel):
"""Request to trigger historical order import."""
# How far back to import orders (days)
days_back: int = Field(90, ge=1, le=365, description="Days of order history to import")
include_products: bool = Field(True, description="Also import products from Letzshop")
class OrderSyncTriggerResponse(BaseModel):
"""Response after triggering order sync job."""
success: bool
message: str
job_id: int | None = None
estimated_duration_minutes: int | None = None
class OrderSyncProgressResponse(BaseModel):
"""Response for order sync progress polling."""
job_id: int
status: str # pending, running, completed, failed
progress_percentage: int = 0
current_phase: str | None = None # products, orders, finalizing
# Counts
orders_imported: int = 0
orders_total: int | None = None
products_imported: int = 0
# Timing
started_at: datetime | None = None
completed_at: datetime | None = None
estimated_remaining_seconds: int | None = None
# Error info if failed
error_message: str | None = None
class OrderSyncCompleteRequest(BaseModel):
"""Request to mark order sync as complete."""
job_id: int
class OrderSyncCompleteResponse(BaseModel):
"""Response after completing order sync step."""
success: bool
step_completed: bool
onboarding_completed: bool = False
message: str | None = None
redirect_url: str | None = None
# =============================================================================
# ADMIN SKIP
# =============================================================================
class OnboardingSkipRequest(BaseModel):
"""Request to skip onboarding (admin only)."""
reason: str = Field(..., min_length=10, max_length=500)
class OnboardingSkipResponse(BaseModel):
"""Response after skipping onboarding."""
success: bool
message: str
vendor_id: int
skipped_at: datetime