REFACTOR ALL APIs
This commit is contained in:
497
services/auth/app/api/onboarding_progress.py
Normal file
497
services/auth/app/api/onboarding_progress.py
Normal file
@@ -0,0 +1,497 @@
|
||||
"""
|
||||
User onboarding progress API routes
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Dict, Any, List, Optional
|
||||
import structlog
|
||||
from datetime import datetime, timezone
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.user_service import UserService
|
||||
from app.repositories.onboarding_repository import OnboardingRepository
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.routing import RouteBuilder
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(tags=["onboarding"])
|
||||
route_builder = RouteBuilder('auth')
|
||||
|
||||
# Request/Response Models
|
||||
class OnboardingStepStatus(BaseModel):
|
||||
step_name: str
|
||||
completed: bool
|
||||
completed_at: Optional[datetime] = None
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
|
||||
class UserProgress(BaseModel):
|
||||
user_id: str
|
||||
steps: List[OnboardingStepStatus]
|
||||
current_step: str
|
||||
next_step: Optional[str] = None
|
||||
completion_percentage: float
|
||||
fully_completed: bool
|
||||
last_updated: datetime
|
||||
|
||||
class UpdateStepRequest(BaseModel):
|
||||
step_name: str
|
||||
completed: bool
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Define the onboarding steps and their order - matching frontend step IDs
|
||||
ONBOARDING_STEPS = [
|
||||
"user_registered", # Auto-completed: User account created
|
||||
"setup", # Step 1: Basic bakery setup and tenant creation
|
||||
"smart-inventory-setup", # Step 2: Sales data upload and inventory configuration
|
||||
"suppliers", # Step 3: Suppliers configuration (optional)
|
||||
"ml-training", # Step 4: AI model training
|
||||
"completion" # Step 5: Onboarding completed, ready to use dashboard
|
||||
]
|
||||
|
||||
STEP_DEPENDENCIES = {
|
||||
"setup": ["user_registered"],
|
||||
"smart-inventory-setup": ["user_registered", "setup"],
|
||||
"suppliers": ["user_registered", "setup", "smart-inventory-setup"], # Optional step
|
||||
"ml-training": ["user_registered", "setup", "smart-inventory-setup"],
|
||||
"completion": ["user_registered", "setup", "smart-inventory-setup", "ml-training"]
|
||||
}
|
||||
|
||||
class OnboardingService:
|
||||
"""Service for managing user onboarding progress"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.user_service = UserService(db)
|
||||
self.onboarding_repo = OnboardingRepository(db)
|
||||
|
||||
async def get_user_progress(self, user_id: str) -> UserProgress:
|
||||
"""Get current onboarding progress for user"""
|
||||
|
||||
# Get user's onboarding data from user preferences or separate table
|
||||
user_progress_data = await self._get_user_onboarding_data(user_id)
|
||||
|
||||
# Calculate current status for each step
|
||||
steps = []
|
||||
completed_steps = []
|
||||
|
||||
for step_name in ONBOARDING_STEPS:
|
||||
step_data = user_progress_data.get(step_name, {})
|
||||
is_completed = step_data.get("completed", False)
|
||||
|
||||
if is_completed:
|
||||
completed_steps.append(step_name)
|
||||
|
||||
steps.append(OnboardingStepStatus(
|
||||
step_name=step_name,
|
||||
completed=is_completed,
|
||||
completed_at=step_data.get("completed_at"),
|
||||
data=step_data.get("data", {})
|
||||
))
|
||||
|
||||
# Determine current and next step
|
||||
current_step = self._get_current_step(completed_steps)
|
||||
next_step = self._get_next_step(completed_steps)
|
||||
|
||||
# Calculate completion percentage
|
||||
completion_percentage = (len(completed_steps) / len(ONBOARDING_STEPS)) * 100
|
||||
|
||||
# Check if fully completed
|
||||
fully_completed = len(completed_steps) == len(ONBOARDING_STEPS)
|
||||
|
||||
return UserProgress(
|
||||
user_id=user_id,
|
||||
steps=steps,
|
||||
current_step=current_step,
|
||||
next_step=next_step,
|
||||
completion_percentage=completion_percentage,
|
||||
fully_completed=fully_completed,
|
||||
last_updated=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
async def update_step(self, user_id: str, update_request: UpdateStepRequest) -> UserProgress:
|
||||
"""Update a specific onboarding step"""
|
||||
|
||||
step_name = update_request.step_name
|
||||
|
||||
# Validate step name
|
||||
if step_name not in ONBOARDING_STEPS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid step name: {step_name}"
|
||||
)
|
||||
|
||||
# Check dependencies if marking as completed
|
||||
if update_request.completed:
|
||||
can_complete = await self._can_complete_step(user_id, step_name)
|
||||
if not can_complete:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot complete step {step_name}: dependencies not met"
|
||||
)
|
||||
|
||||
# Update the step
|
||||
await self._update_user_onboarding_data(
|
||||
user_id,
|
||||
step_name,
|
||||
{
|
||||
"completed": update_request.completed,
|
||||
"completed_at": datetime.now(timezone.utc).isoformat() if update_request.completed else None,
|
||||
"data": update_request.data or {}
|
||||
}
|
||||
)
|
||||
|
||||
# Try to update summary and handle partial failures gracefully
|
||||
try:
|
||||
# Update the user's onboarding summary
|
||||
await self._update_user_summary(user_id)
|
||||
except HTTPException as he:
|
||||
# If it's a 207 Multi-Status (partial success), log warning but continue
|
||||
if he.status_code == status.HTTP_207_MULTI_STATUS:
|
||||
logger.warning(f"Summary update failed for user {user_id}, step {step_name}: {he.detail}")
|
||||
# Continue execution - the step update was successful
|
||||
else:
|
||||
# Re-raise other HTTP exceptions
|
||||
raise
|
||||
|
||||
# Return updated progress
|
||||
return await self.get_user_progress(user_id)
|
||||
|
||||
async def get_next_step(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Get the next required step for user"""
|
||||
progress = await self.get_user_progress(user_id)
|
||||
|
||||
if progress.fully_completed:
|
||||
return {"step": "dashboard_accessible", "completed": True}
|
||||
|
||||
return {"step": progress.next_step or progress.current_step}
|
||||
|
||||
async def can_access_step(self, user_id: str, step_name: str) -> Dict[str, Any]:
|
||||
"""Check if user can access a specific step"""
|
||||
|
||||
if step_name not in ONBOARDING_STEPS:
|
||||
return {"can_access": False, "reason": "Invalid step name"}
|
||||
|
||||
can_access = await self._can_complete_step(user_id, step_name)
|
||||
return {"can_access": can_access}
|
||||
|
||||
async def complete_onboarding(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Mark entire onboarding as complete"""
|
||||
|
||||
# Ensure all steps are completed
|
||||
progress = await self.get_user_progress(user_id)
|
||||
|
||||
if not progress.fully_completed:
|
||||
incomplete_steps = [
|
||||
step.step_name for step in progress.steps if not step.completed
|
||||
]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot complete onboarding: incomplete steps: {incomplete_steps}"
|
||||
)
|
||||
|
||||
# Update user's isOnboardingComplete flag
|
||||
await self.user_service.update_user_field(
|
||||
user_id,
|
||||
"is_onboarding_complete",
|
||||
True
|
||||
)
|
||||
|
||||
return {"success": True, "message": "Onboarding completed successfully"}
|
||||
|
||||
def _get_current_step(self, completed_steps: List[str]) -> str:
|
||||
"""Determine current step based on completed steps"""
|
||||
for step in ONBOARDING_STEPS:
|
||||
if step not in completed_steps:
|
||||
return step
|
||||
return ONBOARDING_STEPS[-1] # All completed
|
||||
|
||||
def _get_next_step(self, completed_steps: List[str]) -> Optional[str]:
|
||||
"""Determine next step based on completed steps"""
|
||||
current_step = self._get_current_step(completed_steps)
|
||||
current_index = ONBOARDING_STEPS.index(current_step)
|
||||
|
||||
if current_index < len(ONBOARDING_STEPS) - 1:
|
||||
return ONBOARDING_STEPS[current_index + 1]
|
||||
|
||||
return None # No next step
|
||||
|
||||
async def _can_complete_step(self, user_id: str, step_name: str) -> bool:
|
||||
"""Check if user can complete a specific step"""
|
||||
|
||||
# Get required dependencies for this step
|
||||
required_steps = STEP_DEPENDENCIES.get(step_name, [])
|
||||
|
||||
if not required_steps:
|
||||
return True # No dependencies
|
||||
|
||||
# Check if all required steps are completed
|
||||
user_progress_data = await self._get_user_onboarding_data(user_id)
|
||||
|
||||
for required_step in required_steps:
|
||||
if not user_progress_data.get(required_step, {}).get("completed", False):
|
||||
return False
|
||||
|
||||
# SPECIAL VALIDATION FOR ML TRAINING STEP
|
||||
if step_name == "ml-training":
|
||||
# Ensure that smart-inventory-setup was completed with sales data imported
|
||||
smart_inventory_data = user_progress_data.get("smart-inventory-setup", {}).get("data", {})
|
||||
|
||||
# Check if sales data was imported successfully
|
||||
sales_import_result = smart_inventory_data.get("salesImportResult", {})
|
||||
has_sales_data_imported = (
|
||||
sales_import_result.get("records_created", 0) > 0 or
|
||||
sales_import_result.get("success", False) or
|
||||
sales_import_result.get("imported", False)
|
||||
)
|
||||
|
||||
if not has_sales_data_imported:
|
||||
logger.warning(f"ML training blocked for user {user_id}: No sales data imported",
|
||||
extra={"sales_import_result": sales_import_result})
|
||||
return False
|
||||
|
||||
# Also check if inventory is configured
|
||||
inventory_configured = smart_inventory_data.get("inventoryConfigured", False)
|
||||
if not inventory_configured:
|
||||
logger.warning(f"ML training blocked for user {user_id}: Inventory not configured")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _get_user_onboarding_data(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Get user's onboarding progress data from storage"""
|
||||
try:
|
||||
# Get all onboarding steps for the user from database
|
||||
steps = await self.onboarding_repo.get_user_progress_steps(user_id)
|
||||
|
||||
# Convert to the expected dictionary format
|
||||
progress_data = {}
|
||||
for step in steps:
|
||||
progress_data[step.step_name] = {
|
||||
"completed": step.completed,
|
||||
"completed_at": step.completed_at,
|
||||
"data": step.step_data or {}
|
||||
}
|
||||
|
||||
return progress_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting onboarding data for user {user_id}: {e}")
|
||||
return {}
|
||||
|
||||
async def _update_user_onboarding_data(
|
||||
self,
|
||||
user_id: str,
|
||||
step_name: str,
|
||||
step_data: Dict[str, Any]
|
||||
):
|
||||
"""Update user's onboarding step data"""
|
||||
try:
|
||||
# Extract the completion status and other data
|
||||
completed = step_data.get("completed", False)
|
||||
data_payload = step_data.get("data", {})
|
||||
|
||||
# Update the step in database
|
||||
updated_step = await self.onboarding_repo.upsert_user_step(
|
||||
user_id=user_id,
|
||||
step_name=step_name,
|
||||
completed=completed,
|
||||
step_data=data_payload
|
||||
)
|
||||
|
||||
logger.info(f"Successfully updated onboarding step for user {user_id}: {step_name} = {step_data}")
|
||||
return updated_step
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating onboarding data for user {user_id}, step {step_name}: {e}")
|
||||
raise
|
||||
|
||||
async def _update_user_summary(self, user_id: str):
|
||||
"""Update user's onboarding summary after step changes"""
|
||||
try:
|
||||
# Get updated progress
|
||||
user_progress_data = await self._get_user_onboarding_data(user_id)
|
||||
|
||||
# Calculate current status
|
||||
completed_steps = []
|
||||
for step_name in ONBOARDING_STEPS:
|
||||
if user_progress_data.get(step_name, {}).get("completed", False):
|
||||
completed_steps.append(step_name)
|
||||
|
||||
# Determine current and next step
|
||||
current_step = self._get_current_step(completed_steps)
|
||||
next_step = self._get_next_step(completed_steps)
|
||||
|
||||
# Calculate completion percentage
|
||||
completion_percentage = (len(completed_steps) / len(ONBOARDING_STEPS)) * 100
|
||||
|
||||
# Check if fully completed
|
||||
fully_completed = len(completed_steps) == len(ONBOARDING_STEPS)
|
||||
|
||||
# Format steps count
|
||||
steps_completed_count = f"{len(completed_steps)}/{len(ONBOARDING_STEPS)}"
|
||||
|
||||
# Update summary in database
|
||||
await self.onboarding_repo.upsert_user_summary(
|
||||
user_id=user_id,
|
||||
current_step=current_step,
|
||||
next_step=next_step,
|
||||
completion_percentage=completion_percentage,
|
||||
fully_completed=fully_completed,
|
||||
steps_completed_count=steps_completed_count
|
||||
)
|
||||
|
||||
logger.debug(f"Successfully updated onboarding summary for user {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating onboarding summary for user {user_id}: {e}",
|
||||
extra={"user_id": user_id, "error_type": type(e).__name__})
|
||||
# Raise a warning-level HTTPException to inform frontend without breaking the flow
|
||||
# This allows the step update to succeed while alerting about summary issues
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_207_MULTI_STATUS,
|
||||
detail=f"Step updated successfully, but summary update failed: {str(e)}"
|
||||
)
|
||||
|
||||
# API Routes
|
||||
|
||||
@router.get(route_builder.build_base_route("me/onboarding/progress", include_tenant_prefix=False), response_model=UserProgress)
|
||||
async def get_user_progress(
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get current user's onboarding progress"""
|
||||
|
||||
try:
|
||||
onboarding_service = OnboardingService(db)
|
||||
progress = await onboarding_service.get_user_progress(current_user["user_id"])
|
||||
return progress
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get onboarding progress error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get onboarding progress"
|
||||
)
|
||||
|
||||
@router.get(route_builder.build_base_route("{user_id}/onboarding/progress", include_tenant_prefix=False), response_model=UserProgress)
|
||||
async def get_user_progress_by_id(
|
||||
user_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get onboarding progress for a specific user
|
||||
Available for service-to-service calls and admin users
|
||||
"""
|
||||
|
||||
# Allow service tokens or admin users
|
||||
user_type = current_user.get("type", "user")
|
||||
user_role = current_user.get("role", "user")
|
||||
|
||||
if user_type != "service" and user_role not in ["admin", "super_admin"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions to access other users' onboarding progress"
|
||||
)
|
||||
|
||||
try:
|
||||
onboarding_service = OnboardingService(db)
|
||||
progress = await onboarding_service.get_user_progress(user_id)
|
||||
return progress
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get onboarding progress error for user {user_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get onboarding progress"
|
||||
)
|
||||
|
||||
@router.put(route_builder.build_base_route("me/onboarding/step", include_tenant_prefix=False), response_model=UserProgress)
|
||||
async def update_onboarding_step(
|
||||
update_request: UpdateStepRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update a specific onboarding step"""
|
||||
|
||||
try:
|
||||
onboarding_service = OnboardingService(db)
|
||||
progress = await onboarding_service.update_step(
|
||||
current_user["user_id"],
|
||||
update_request
|
||||
)
|
||||
return progress
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Update onboarding step error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update onboarding step"
|
||||
)
|
||||
|
||||
@router.get(route_builder.build_base_route("me/onboarding/next-step", include_tenant_prefix=False))
|
||||
async def get_next_step(
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get next required step for user"""
|
||||
|
||||
try:
|
||||
onboarding_service = OnboardingService(db)
|
||||
result = await onboarding_service.get_next_step(current_user["user_id"])
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get next step error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get next step"
|
||||
)
|
||||
|
||||
@router.get(route_builder.build_base_route("me/onboarding/can-access/{step_name}", include_tenant_prefix=False))
|
||||
async def can_access_step(
|
||||
step_name: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Check if user can access a specific step"""
|
||||
|
||||
try:
|
||||
onboarding_service = OnboardingService(db)
|
||||
result = await onboarding_service.can_access_step(
|
||||
current_user["user_id"],
|
||||
step_name
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Can access step error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to check step access"
|
||||
)
|
||||
|
||||
@router.post(route_builder.build_base_route("me/onboarding/complete", include_tenant_prefix=False))
|
||||
async def complete_onboarding(
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Complete entire onboarding process"""
|
||||
|
||||
try:
|
||||
onboarding_service = OnboardingService(db)
|
||||
result = await onboarding_service.complete_onboarding(current_user["user_id"])
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Complete onboarding error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to complete onboarding"
|
||||
)
|
||||
Reference in New Issue
Block a user