Files
orion/app/modules/marketplace/services/onboarding_service.py
Samir Boulahtit e9253fbd84 refactor: rename Wizamart to Orion across entire codebase
Replace all ~1,086 occurrences of Wizamart/wizamart/WIZAMART/WizaMart
with Orion/orion/ORION across 184 files. This includes database
identifiers, email addresses, domain references, R2 bucket names,
DNS prefixes, encryption salt, Celery app name, config defaults,
Docker configs, CI configs, documentation, seed data, and templates.

Renames homepage-wizamart.html template to homepage-orion.html.
Fixes duplicate file_pattern key in api.yaml architecture rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:46:56 +01:00

664 lines
23 KiB
Python

# app/modules/marketplace/services/onboarding_service.py
"""
Store onboarding service.
Handles the 4-step mandatory onboarding wizard for new stores:
1. Merchant Profile Setup
2. Letzshop API Configuration
3. Product & Order Import (CSV feed URL configuration)
4. Order Sync (historical import with progress tracking)
"""
import logging
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.modules.marketplace.exceptions import (
OnboardingCsvUrlRequiredException,
OnboardingNotFoundException,
OnboardingStepOrderException,
OnboardingSyncJobNotFoundException,
OnboardingSyncNotCompleteException,
)
from app.modules.marketplace.models import (
OnboardingStatus,
OnboardingStep,
StoreOnboarding,
)
from app.modules.marketplace.services.letzshop import (
LetzshopCredentialsService,
LetzshopOrderService,
)
from app.modules.tenancy.exceptions import StoreNotFoundException
from app.modules.tenancy.models import Store
logger = logging.getLogger(__name__)
class OnboardingService:
"""
Service for managing store onboarding workflow.
Provides methods for each onboarding step and progress tracking.
"""
def __init__(self, db: Session):
"""
Initialize the onboarding service.
Args:
db: SQLAlchemy database session.
"""
self.db = db
# =========================================================================
# Onboarding CRUD
# =========================================================================
def get_onboarding(self, store_id: int) -> StoreOnboarding | None:
"""Get onboarding record for a store."""
return (
self.db.query(StoreOnboarding)
.filter(StoreOnboarding.store_id == store_id)
.first()
)
def get_onboarding_or_raise(self, store_id: int) -> StoreOnboarding:
"""Get onboarding record or raise OnboardingNotFoundException."""
onboarding = self.get_onboarding(store_id)
if onboarding is None:
raise OnboardingNotFoundException(store_id)
return onboarding
def create_onboarding(self, store_id: int) -> StoreOnboarding:
"""
Create a new onboarding record for a store.
This is called automatically when a store is created during signup.
"""
# Check if already exists
existing = self.get_onboarding(store_id)
if existing:
logger.warning(f"Onboarding already exists for store {store_id}")
return existing
onboarding = StoreOnboarding(
store_id=store_id,
status=OnboardingStatus.NOT_STARTED.value,
current_step=OnboardingStep.MERCHANT_PROFILE.value,
)
self.db.add(onboarding)
self.db.flush()
logger.info(f"Created onboarding record for store {store_id}")
return onboarding
def get_or_create_onboarding(self, store_id: int) -> StoreOnboarding:
"""Get existing onboarding or create new one."""
onboarding = self.get_onboarding(store_id)
if onboarding is None:
onboarding = self.create_onboarding(store_id)
return onboarding
# =========================================================================
# Status Helpers
# =========================================================================
def is_completed(self, store_id: int) -> bool:
"""Check if onboarding is completed for a store."""
onboarding = self.get_onboarding(store_id)
if onboarding is None:
return False
return onboarding.is_completed
def get_status_response(self, store_id: int) -> dict:
"""
Get full onboarding status for API response.
Returns a dictionary with all step statuses and progress information.
"""
onboarding = self.get_or_create_onboarding(store_id)
return {
"id": onboarding.id,
"store_id": onboarding.store_id,
"status": onboarding.status,
"current_step": onboarding.current_step,
# Step statuses
"merchant_profile": {
"completed": onboarding.step_merchant_profile_completed,
"completed_at": onboarding.step_merchant_profile_completed_at,
"data": onboarding.step_merchant_profile_data,
},
"letzshop_api": {
"completed": onboarding.step_letzshop_api_completed,
"completed_at": onboarding.step_letzshop_api_completed_at,
"connection_verified": onboarding.step_letzshop_api_connection_verified,
},
"product_import": {
"completed": onboarding.step_product_import_completed,
"completed_at": onboarding.step_product_import_completed_at,
"csv_url_set": onboarding.step_product_import_csv_url_set,
},
"order_sync": {
"completed": onboarding.step_order_sync_completed,
"completed_at": onboarding.step_order_sync_completed_at,
"job_id": onboarding.step_order_sync_job_id,
},
# Progress tracking
"completion_percentage": onboarding.completion_percentage,
"completed_steps_count": onboarding.completed_steps_count,
"total_steps": 4,
# Completion info
"is_completed": onboarding.is_completed,
"started_at": onboarding.started_at,
"completed_at": onboarding.completed_at,
# Admin override info
"skipped_by_admin": onboarding.skipped_by_admin,
"skipped_at": onboarding.skipped_at,
"skipped_reason": onboarding.skipped_reason,
}
# =========================================================================
# Step 1: Merchant Profile
# =========================================================================
def get_merchant_profile_data(self, store_id: int) -> dict:
"""Get current merchant profile data for editing."""
store = self.db.query(Store).filter(Store.id == store_id).first()
if not store:
return {}
merchant = store.merchant
return {
"merchant_name": merchant.name if merchant else None,
"brand_name": store.name,
"description": store.description,
"contact_email": store.effective_contact_email,
"contact_phone": store.effective_contact_phone,
"website": store.effective_website,
"business_address": store.effective_business_address,
"tax_number": store.effective_tax_number,
"default_language": store.default_language,
"dashboard_language": store.dashboard_language,
}
def complete_merchant_profile(
self,
store_id: int,
merchant_name: str | None = None,
brand_name: str | None = None,
description: str | None = None,
contact_email: str | None = None,
contact_phone: str | None = None,
website: str | None = None,
business_address: str | None = None,
tax_number: str | None = None,
default_language: str = "fr",
dashboard_language: str = "fr",
) -> dict:
"""
Save merchant profile and mark Step 1 as complete.
Returns response with next step information.
"""
# Check store exists BEFORE creating onboarding record (FK constraint)
store = self.db.query(Store).filter(Store.id == store_id).first()
if not store:
raise StoreNotFoundException(store_id)
onboarding = self.get_or_create_onboarding(store_id)
# Update onboarding status if this is the first step
if onboarding.status == OnboardingStatus.NOT_STARTED.value:
onboarding.status = OnboardingStatus.IN_PROGRESS.value
onboarding.started_at = datetime.now(UTC)
merchant = store.merchant
# Update merchant name if provided
if merchant and merchant_name:
merchant.name = merchant_name
# Update store fields
if brand_name:
store.name = brand_name
if description is not None:
store.description = description
# Update contact info (store-level overrides)
store.contact_email = contact_email
store.contact_phone = contact_phone
store.website = website
store.business_address = business_address
store.tax_number = tax_number
# Update language settings
store.default_language = default_language
store.dashboard_language = dashboard_language
# Store profile data in onboarding record
onboarding.step_merchant_profile_data = {
"merchant_name": merchant_name,
"brand_name": brand_name,
"description": description,
"contact_email": contact_email,
"contact_phone": contact_phone,
"website": website,
"business_address": business_address,
"tax_number": tax_number,
"default_language": default_language,
"dashboard_language": dashboard_language,
}
# Mark step complete
onboarding.mark_step_complete(OnboardingStep.MERCHANT_PROFILE.value)
self.db.flush()
logger.info(f"Completed merchant profile step for store {store_id}")
return {
"success": True,
"step_completed": True,
"next_step": onboarding.current_step,
"message": "Merchant profile saved successfully",
}
# =========================================================================
# Step 2: Letzshop API Configuration
# =========================================================================
def test_letzshop_api(
self,
api_key: str,
shop_slug: str,
) -> dict:
"""
Test Letzshop API connection without saving credentials.
Returns connection test result with store info if successful.
"""
credentials_service = LetzshopCredentialsService(self.db)
# Test the API key
success, response_time, error = credentials_service.test_api_key(api_key)
if success:
return {
"success": True,
"message": f"Connection successful ({response_time:.0f}ms)",
"store_name": None, # Would need to query Letzshop for this
"store_id": None,
"shop_slug": shop_slug,
}
return {
"success": False,
"message": error or "Connection failed",
"store_name": None,
"store_id": None,
"shop_slug": None,
}
def complete_letzshop_api(
self,
store_id: int,
api_key: str,
shop_slug: str,
letzshop_store_id: str | None = None,
) -> dict:
"""
Save Letzshop API credentials and mark Step 2 as complete.
Tests connection first, only saves if successful.
"""
onboarding = self.get_or_create_onboarding(store_id)
# Verify step order
if not onboarding.can_proceed_to_step(OnboardingStep.LETZSHOP_API.value):
raise OnboardingStepOrderException(
current_step=onboarding.current_step,
required_step=OnboardingStep.MERCHANT_PROFILE.value,
)
# Test connection first
credentials_service = LetzshopCredentialsService(self.db)
success, response_time, error = credentials_service.test_api_key(api_key)
if not success:
return {
"success": False,
"step_completed": False,
"next_step": None,
"message": f"Connection test failed: {error}",
"connection_verified": False,
}
# Save credentials
credentials_service.upsert_credentials(
store_id=store_id,
api_key=api_key,
auto_sync_enabled=False, # Enable after onboarding
sync_interval_minutes=15,
)
# Update store with Letzshop identity
store = self.db.query(Store).filter(Store.id == store_id).first()
if store:
store.letzshop_store_slug = shop_slug
if letzshop_store_id:
store.letzshop_store_id = letzshop_store_id
# Mark step complete
onboarding.step_letzshop_api_connection_verified = True
onboarding.mark_step_complete(OnboardingStep.LETZSHOP_API.value)
self.db.flush()
logger.info(f"Completed Letzshop API step for store {store_id}")
return {
"success": True,
"step_completed": True,
"next_step": onboarding.current_step,
"message": "Letzshop API configured successfully",
"connection_verified": True,
}
# =========================================================================
# Step 3: Product & Order Import Configuration
# =========================================================================
def get_product_import_config(self, store_id: int) -> dict:
"""Get current product import configuration."""
store = self.db.query(Store).filter(Store.id == store_id).first()
if not store:
return {}
return {
"csv_url_fr": store.letzshop_csv_url_fr,
"csv_url_en": store.letzshop_csv_url_en,
"csv_url_de": store.letzshop_csv_url_de,
"default_tax_rate": store.letzshop_default_tax_rate,
"delivery_method": store.letzshop_delivery_method,
"preorder_days": store.letzshop_preorder_days,
}
def complete_product_import(
self,
store_id: int,
csv_url_fr: str | None = None,
csv_url_en: str | None = None,
csv_url_de: str | None = None,
default_tax_rate: int = 17,
delivery_method: str = "package_delivery",
preorder_days: int = 1,
) -> dict:
"""
Save product import configuration and mark Step 3 as complete.
At least one CSV URL must be provided.
"""
onboarding = self.get_or_create_onboarding(store_id)
# Verify step order
if not onboarding.can_proceed_to_step(OnboardingStep.PRODUCT_IMPORT.value):
raise OnboardingStepOrderException(
current_step=onboarding.current_step,
required_step=OnboardingStep.LETZSHOP_API.value,
)
# Validate at least one CSV URL
csv_urls_count = sum([
bool(csv_url_fr),
bool(csv_url_en),
bool(csv_url_de),
])
if csv_urls_count == 0:
raise OnboardingCsvUrlRequiredException()
# Update store settings
store = self.db.query(Store).filter(Store.id == store_id).first()
if not store:
raise StoreNotFoundException(store_id)
store.letzshop_csv_url_fr = csv_url_fr
store.letzshop_csv_url_en = csv_url_en
store.letzshop_csv_url_de = csv_url_de
store.letzshop_default_tax_rate = default_tax_rate
store.letzshop_delivery_method = delivery_method
store.letzshop_preorder_days = preorder_days
# Mark step complete
onboarding.step_product_import_csv_url_set = True
onboarding.mark_step_complete(OnboardingStep.PRODUCT_IMPORT.value)
self.db.flush()
logger.info(f"Completed product import step for store {store_id}")
return {
"success": True,
"step_completed": True,
"next_step": onboarding.current_step,
"message": "Product import configured successfully",
"csv_urls_configured": csv_urls_count,
}
# =========================================================================
# Step 4: Order Sync
# =========================================================================
def trigger_order_sync(
self,
store_id: int,
user_id: int,
days_back: int = 90,
include_products: bool = True,
) -> dict:
"""
Trigger historical order import and return job info.
Creates a background job that imports historical orders from Letzshop.
"""
onboarding = self.get_or_create_onboarding(store_id)
# Verify step order
if not onboarding.can_proceed_to_step(OnboardingStep.ORDER_SYNC.value):
raise OnboardingStepOrderException(
current_step=onboarding.current_step,
required_step=OnboardingStep.PRODUCT_IMPORT.value,
)
# Create historical import job
order_service = LetzshopOrderService(self.db)
# Check for existing running job
existing_job = order_service.get_running_historical_import_job(store_id)
if existing_job:
return {
"success": True,
"message": "Import job already running",
"job_id": existing_job.id,
"estimated_duration_minutes": 5, # Estimate
}
# Create new job
job = order_service.create_historical_import_job(
store_id=store_id,
user_id=user_id,
)
# Store job ID in onboarding
onboarding.step_order_sync_job_id = job.id
self.db.flush()
logger.info(f"Triggered order sync job {job.id} for store {store_id}")
return {
"success": True,
"message": "Historical import started",
"job_id": job.id,
"estimated_duration_minutes": 5, # Estimate
}
def get_order_sync_progress(
self,
store_id: int,
job_id: int,
) -> dict:
"""
Get progress of historical import job.
Returns current status, progress, and counts.
"""
order_service = LetzshopOrderService(self.db)
job = order_service.get_historical_import_job_by_id(store_id, job_id)
if not job:
return {
"job_id": job_id,
"status": "not_found",
"progress_percentage": 0,
"current_phase": None,
"orders_imported": 0,
"orders_total": None,
"products_imported": 0,
"started_at": None,
"completed_at": None,
"estimated_remaining_seconds": None,
"error_message": "Job not found",
}
# Calculate progress percentage
progress = 0
if job.status == "completed":
progress = 100
elif job.status == "failed":
progress = 0
elif job.status == "processing":
# Use orders_processed and shipments_fetched for progress
total = job.shipments_fetched or 0
processed = job.orders_processed or 0
if total > 0:
progress = int(processed / total * 100)
else:
progress = 50 # Indeterminate
elif job.status == "fetching":
# Show partial progress during fetch phase
if job.total_pages and job.total_pages > 0:
progress = int((job.current_page or 0) / job.total_pages * 50)
else:
progress = 25 # Indeterminate
# Determine current phase
current_phase = job.current_phase or job.status
return {
"job_id": job.id,
"status": job.status,
"progress_percentage": progress,
"current_phase": current_phase,
"orders_imported": job.orders_imported or 0,
"orders_total": job.shipments_fetched,
"products_imported": job.products_matched or 0,
"started_at": job.started_at,
"completed_at": job.completed_at,
"estimated_remaining_seconds": None, # TODO: Calculate
"error_message": job.error_message,
}
def complete_order_sync(
self,
store_id: int,
job_id: int,
) -> dict:
"""
Mark order sync step as complete after job finishes.
Also marks the entire onboarding as complete.
"""
onboarding = self.get_or_create_onboarding(store_id)
# Verify job is complete
order_service = LetzshopOrderService(self.db)
job = order_service.get_historical_import_job_by_id(store_id, job_id)
if not job:
raise OnboardingSyncJobNotFoundException(job_id)
if job.status not in ("completed", "failed"):
raise OnboardingSyncNotCompleteException(job.status)
# Mark step complete (even if job failed - they can retry later)
onboarding.mark_step_complete(OnboardingStep.ORDER_SYNC.value)
# Enable auto-sync now that onboarding is complete
credentials_service = LetzshopCredentialsService(self.db)
credentials = credentials_service.get_credentials(store_id)
if credentials:
credentials.auto_sync_enabled = True
self.db.flush()
# Get store code for redirect URL
store = self.db.query(Store).filter(Store.id == store_id).first()
store_code = store.store_code if store else ""
logger.info(f"Completed onboarding for store {store_id}")
return {
"success": True,
"step_completed": True,
"onboarding_completed": True,
"message": "Onboarding complete! Welcome to Orion.",
"redirect_url": f"/store/{store_code}/dashboard",
}
# =========================================================================
# Admin Skip
# =========================================================================
def skip_onboarding(
self,
store_id: int,
admin_user_id: int,
reason: str,
) -> dict:
"""
Admin-only: Skip onboarding for a store.
Used for support cases where manual setup is needed.
"""
onboarding = self.get_or_create_onboarding(store_id)
onboarding.skipped_by_admin = True
onboarding.skipped_at = datetime.now(UTC)
onboarding.skipped_reason = reason
onboarding.skipped_by_user_id = admin_user_id
onboarding.status = OnboardingStatus.SKIPPED.value
self.db.flush()
logger.info(
f"Admin {admin_user_id} skipped onboarding for store {store_id}: {reason}"
)
return {
"success": True,
"message": "Onboarding skipped by admin",
"store_id": store_id,
"skipped_at": onboarding.skipped_at,
}
# Singleton-style convenience instance
def get_onboarding_service(db: Session) -> OnboardingService:
"""Get an OnboardingService instance."""
return OnboardingService(db)