# ================================================================ # 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))