Some checks failed
Enforce MOD-025/MOD-026 rules: zero top-level cross-module model imports remain in any service file. All 66 files migrated using deferred import patterns (method-body, _get_model() helpers, instance-cached self._Model) and new cross-module service methods in tenancy. Documentation updated with Pattern 6 (deferred imports), migration plan marked complete, and violations status reflects 84→0 service-layer violations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
669 lines
23 KiB
Python
669 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
|
|
|
|
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
|
|
|
|
def _get_store(self, store_id: int):
|
|
"""Get store by ID via store service."""
|
|
from app.modules.tenancy.services.store_service import store_service
|
|
|
|
return store_service.get_store_by_id_optional(self.db, store_id)
|
|
|
|
# =========================================================================
|
|
# 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._get_store(store_id)
|
|
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._get_store(store_id)
|
|
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._get_store(store_id)
|
|
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._get_store(store_id)
|
|
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._get_store(store_id)
|
|
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._get_store(store_id)
|
|
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)
|