Files
bakery-ia/services/orchestrator/app/api/orchestration.py

347 lines
12 KiB
Python

# ================================================================
# services/orchestrator/app/api/orchestration.py
# ================================================================
"""
Orchestration API Endpoints
Testing and manual trigger endpoints for orchestration
"""
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
import structlog
from app.core.database import get_db
from app.repositories.orchestration_run_repository import OrchestrationRunRepository
from sqlalchemy.ext.asyncio import AsyncSession
logger = structlog.get_logger()
router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/orchestrator", tags=["Orchestration"])
# ================================================================
# REQUEST/RESPONSE SCHEMAS
# ================================================================
class OrchestratorTestRequest(BaseModel):
"""Request schema for testing orchestrator"""
test_scenario: Optional[str] = Field(None, description="Test scenario: full, production_only, procurement_only")
dry_run: bool = Field(False, description="Dry run mode (no actual changes)")
class OrchestratorTestResponse(BaseModel):
"""Response schema for orchestrator test"""
success: bool
message: str
tenant_id: str
forecasting_completed: bool = False
production_completed: bool = False
procurement_completed: bool = False
notifications_sent: bool = False
summary: dict = {}
class OrchestratorWorkflowRequest(BaseModel):
"""Request schema for daily workflow trigger"""
dry_run: bool = Field(False, description="Dry run mode (no actual changes)")
class OrchestratorWorkflowResponse(BaseModel):
"""Response schema for daily workflow trigger"""
success: bool
message: str
tenant_id: str
run_id: Optional[str] = None
forecasting_completed: bool = False
production_completed: bool = False
procurement_completed: bool = False
notifications_sent: bool = False
summary: dict = {}
# ================================================================
# API ENDPOINTS
# ================================================================
@router.post("/test", response_model=OrchestratorTestResponse)
async def trigger_orchestrator_test(
tenant_id: str,
request_data: OrchestratorTestRequest,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Trigger orchestrator for testing purposes
This endpoint allows manual triggering of the orchestration workflow
for a specific tenant, useful for testing during development.
Args:
tenant_id: Tenant ID to orchestrate
request_data: Test request with scenario and dry_run options
request: FastAPI request object
db: Database session
Returns:
OrchestratorTestResponse with results
"""
logger.info("Orchestrator test trigger requested",
tenant_id=tenant_id,
test_scenario=request_data.test_scenario,
dry_run=request_data.dry_run)
try:
# Get scheduler service from app state
if not hasattr(request.app.state, 'scheduler_service'):
raise HTTPException(
status_code=503,
detail="Orchestrator scheduler service not available"
)
scheduler_service = request.app.state.scheduler_service
# Trigger orchestration
tenant_uuid = uuid.UUID(tenant_id)
result = await scheduler_service.trigger_orchestration_for_tenant(
tenant_id=tenant_uuid,
test_scenario=request_data.test_scenario
)
# Get the latest run for this tenant
repo = OrchestrationRunRepository(db)
latest_run = await repo.get_latest_run_for_tenant(tenant_uuid)
# Build response
response = OrchestratorTestResponse(
success=result.get('success', False),
message=result.get('message', 'Orchestration completed'),
tenant_id=tenant_id,
forecasting_completed=latest_run.forecasting_status == 'success' if latest_run else False,
production_completed=latest_run.production_status == 'success' if latest_run else False,
procurement_completed=latest_run.procurement_status == 'success' if latest_run else False,
notifications_sent=latest_run.notification_status == 'success' if latest_run else False,
summary={
'forecasts_generated': latest_run.forecasts_generated if latest_run else 0,
'batches_created': latest_run.production_batches_created if latest_run else 0,
'pos_created': latest_run.purchase_orders_created if latest_run else 0,
'notifications_sent': latest_run.notifications_sent if latest_run else 0
}
)
logger.info("Orchestrator test completed",
tenant_id=tenant_id,
success=response.success)
return response
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid tenant ID: {str(e)}")
except Exception as e:
logger.error("Orchestrator test failed",
tenant_id=tenant_id,
error=str(e),
exc_info=True)
raise HTTPException(status_code=500, detail=f"Orchestrator test failed: {str(e)}")
@router.post("/run-daily-workflow", response_model=OrchestratorWorkflowResponse)
async def run_daily_workflow(
tenant_id: str,
request_data: Optional[OrchestratorWorkflowRequest] = None,
request: Request = None,
db: AsyncSession = Depends(get_db)
):
"""
Trigger the daily orchestrated workflow for a tenant
This endpoint runs the complete daily workflow which includes:
1. Forecasting Service: Generate demand forecasts
2. Production Service: Create production schedule from forecasts
3. Procurement Service: Generate procurement plan
4. Notification Service: Send relevant notifications
This is the production endpoint used by the dashboard scheduler button.
Args:
tenant_id: Tenant ID to orchestrate
request_data: Optional request data with dry_run flag
request: FastAPI request object
db: Database session
Returns:
OrchestratorWorkflowResponse with workflow execution results
"""
logger.info("Daily workflow trigger requested", tenant_id=tenant_id)
# Handle optional request_data
if request_data is None:
request_data = OrchestratorWorkflowRequest()
try:
# Get scheduler service from app state
if not hasattr(request.app.state, 'scheduler_service'):
raise HTTPException(
status_code=503,
detail="Orchestrator scheduler service not available"
)
scheduler_service = request.app.state.scheduler_service
# Trigger orchestration (use full workflow, not test scenario)
tenant_uuid = uuid.UUID(tenant_id)
result = await scheduler_service.trigger_orchestration_for_tenant(
tenant_id=tenant_uuid,
test_scenario=None # Full production workflow
)
# Get the latest run for this tenant
repo = OrchestrationRunRepository(db)
latest_run = await repo.get_latest_run_for_tenant(tenant_uuid)
# Build response
response = OrchestratorWorkflowResponse(
success=result.get('success', False),
message=result.get('message', 'Daily workflow completed successfully'),
tenant_id=tenant_id,
run_id=str(latest_run.id) if latest_run else None,
forecasting_completed=latest_run.forecasting_status == 'success' if latest_run else False,
production_completed=latest_run.production_status == 'success' if latest_run else False,
procurement_completed=latest_run.procurement_status == 'success' if latest_run else False,
notifications_sent=latest_run.notification_status == 'success' if latest_run else False,
summary={
'run_number': latest_run.run_number if latest_run else 0,
'forecasts_generated': latest_run.forecasts_generated if latest_run else 0,
'production_batches_created': latest_run.production_batches_created if latest_run else 0,
'purchase_orders_created': latest_run.purchase_orders_created if latest_run else 0,
'notifications_sent': latest_run.notifications_sent if latest_run else 0,
'duration_seconds': latest_run.duration_seconds if latest_run else 0
}
)
logger.info("Daily workflow completed",
tenant_id=tenant_id,
success=response.success,
run_id=response.run_id)
return response
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid tenant ID: {str(e)}")
except Exception as e:
logger.error("Daily workflow failed",
tenant_id=tenant_id,
error=str(e),
exc_info=True)
raise HTTPException(status_code=500, detail=f"Daily workflow failed: {str(e)}")
@router.get("/health")
async def orchestrator_health():
"""Check orchestrator health"""
return {
"status": "healthy",
"service": "orchestrator",
"message": "Orchestrator service is running"
}
@router.get("/runs", response_model=dict)
async def list_orchestration_runs(
tenant_id: str,
limit: int = 10,
offset: int = 0,
db: AsyncSession = Depends(get_db)
):
"""
List orchestration runs for a tenant
Args:
tenant_id: Tenant ID
limit: Maximum number of runs to return
offset: Number of runs to skip
db: Database session
Returns:
List of orchestration runs
"""
try:
tenant_uuid = uuid.UUID(tenant_id)
repo = OrchestrationRunRepository(db)
runs = await repo.list_runs(
tenant_id=tenant_uuid,
limit=limit,
offset=offset
)
return {
"runs": [
{
"id": str(run.id),
"run_number": run.run_number,
"status": run.status.value,
"started_at": run.started_at.isoformat() if run.started_at else None,
"completed_at": run.completed_at.isoformat() if run.completed_at else None,
"duration_seconds": run.duration_seconds,
"forecasts_generated": run.forecasts_generated,
"batches_created": run.production_batches_created,
"pos_created": run.purchase_orders_created
}
for run in runs
],
"total": len(runs),
"limit": limit,
"offset": offset
}
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid tenant ID: {str(e)}")
except Exception as e:
logger.error("Error listing orchestration runs",
tenant_id=tenant_id,
error=str(e))
raise HTTPException(status_code=500, detail=str(e))
@router.get("/last-run")
async def get_last_orchestration_run(
tenant_id: str,
db: AsyncSession = Depends(get_db)
):
"""
Get timestamp of last orchestration run
Lightweight endpoint for health status frontend migration (Phase 4).
Returns only timestamp and run number for the most recent completed run.
Args:
tenant_id: Tenant ID
Returns:
Dict with timestamp and runNumber (or None if no runs)
"""
try:
tenant_uuid = uuid.UUID(tenant_id)
repo = OrchestrationRunRepository(db)
# Get most recent completed run
latest_run = await repo.get_latest_run_for_tenant(tenant_uuid)
if not latest_run:
return {"timestamp": None, "runNumber": None}
return {
"timestamp": latest_run.started_at.isoformat() if latest_run.started_at else None,
"runNumber": latest_run.run_number
}
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid tenant ID: {str(e)}")
except Exception as e:
logger.error("Error getting last orchestration run",
tenant_id=tenant_id,
error=str(e))
raise HTTPException(status_code=500, detail=str(e))