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>
664 lines
23 KiB
Python
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)
|