347 lines
12 KiB
Python
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))
|