import json from datetime import datetime from typing import List, Optional from connections.db import get_db from connections.keycloak import ( get_current_token_payload, ) from connections.minio import ( ensure_bucket_exists, get_file_from_minio, upload_file_to_minio, ) from connections.rabbitmq import ( bind_queues_to_team_exchange, publish_task_to_team, ) from crud.experiment_crud import ( create_experiment, create_experiment_type, create_instance, delete_experiment, delete_instance, get_all_experiment_types, get_experiment_instances, get_experiment_type_by_id, get_single_experiment, get_single_instance, get_user_experiments, set_simulation_result, update_experiment, update_experiment_type, update_instance, ) # Get team's systems to find max qubits available from crud.team_crud import ( check_team_permission, get_team, # You'll need to import this ) from fastapi import Depends, File, Query, UploadFile from fastapi.param_functions import Form from fastapi.routing import APIRouter from rest_models.experiment_models import ( CreateExperimentRequest, CreateExperimentTypeResponse, CreateInstanceRequest, ExperimentData, ExperimentListResponse, ExperimentTypeList, InstanceData, InstanceListResponse, SimpleInstanceData, SimulationResultData, StartExperimentRequest, UpdateExperimentRequest, UpdateInstanceRequest, ) from rest_models.machine_models import ( ComputationalSystemShortData, ) from rest_models.team_models import ( TeamsShortListResponse, ) from sqlalchemy.ext.asyncio import AsyncSession from starlette.exceptions import HTTPException from starlette.responses import StreamingResponse from starlette.status import HTTP_200_OK router = APIRouter(tags=["Experiments"]) # ============= HELPER FUNC =========================== # async def validate_instance_qubits( db: AsyncSession, team_id: int, qubits_needed: int ) -> None: """ Validate that qubits_needed is within allowed range for the team. Args: db: Database session team_id: Team ID to check systems for qubits_needed: Number of qubits needed for the instance Raises: HTTPException: If validation fails """ # Check qubits is positive if qubits_needed <= 0: raise HTTPException( 400, f"Invalid qubits_needed: {qubits_needed}. Qubits must be greater than 0.", ) team_systems = await get_team(db, team_id) if not team_systems: raise HTTPException( 400, "Team has no computational systems configured. " "Cannot validate qubits requirement.", ) max_qubits = max(system.qubits_given for system in team_systems.team_systems) if qubits_needed > max_qubits: raise HTTPException( 400, f"Instance requires {qubits_needed} qubits, but the maximum available " f"in team's systems is {max_qubits} qubits.", ) def get_experiment_collective_status(simulation_statuses: list[str]) -> str: """ Get the collective status of an experiment based on its simulation statuses. Args: simulation_statuses: List of simulation status strings (e.g., ["DRAFT", "COMPLETE", ...]) Returns: Collective experiment status: "DRAFT", "PROCESSING", "IN QUEUE", or "COMPLETE" """ if not simulation_statuses: return "DRAFT" # Check if all are DRAFT (or not set/empty) if all(status == "DRAFT" for status in simulation_statuses): return "DRAFT" # Check if any is PROCESSING if any( status == "PROCESSING" or status == "IN SYSTEM" for status in simulation_statuses ): return "RUNNING" # Check if any is IN QUEUE (and none are PROCESSING) if any(status == "IN QUEUE" for status in simulation_statuses): return "IN QUEUE" # If all are COMPLETE or ERROR if all(status in ["COMPLETE", "ERROR"] for status in simulation_statuses): if any(status == "ERROR" for status in simulation_statuses): return "COMPLETE WITH ERROR" return "COMPLETE" # Default fallback (should not happen with valid statuses) return "DRAFT" # ============= EXPERIMENT TYPE ENDPOINTS ============= @router.get("/types", response_model=List[ExperimentTypeList]) async def get_experiment_types_request( db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ) -> List[ExperimentTypeList]: """Get all existing experiment types""" keycloak_id = payload.get("sub") if not keycloak_id: raise HTTPException(403, "Permission denied") experiment_types = await get_all_experiment_types(db) return [ ExperimentTypeList( id=exp_type.id, name=exp_type.name, description=exp_type.description, ) for exp_type in experiment_types ] @router.post("/types", response_model=CreateExperimentTypeResponse) async def create_experiment_type_request( name: str = Form(), file_frontend: UploadFile = File(...), file_comp_system: UploadFile = File(...), description: Optional[str] = None, db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ) -> CreateExperimentTypeResponse: """Create a new experiment type with file uploads to MinIO (admin only)""" # Check authentication keycloak_id = payload.get("sub") if not keycloak_id: raise HTTPException(status_code=403, detail="Permission denied") # TODO: Add admin permission check here if needed # Ensure MinIO bucket exists await ensure_bucket_exists() # First create the experiment type in DB to get ID experiment_type = await create_experiment_type( db=db, name=name, description=description, file_frontend="", # Temporary, will update file_comp_system="", # Temporary, will update ) try: # Upload files using the new ID frontend_path = await upload_file_to_minio( file=file_frontend, experiment_type_id=experiment_type.id ) comp_system_path = await upload_file_to_minio( file=file_comp_system, experiment_type_id=experiment_type.id ) # Update experiment type with file paths experiment_type.file_frontend = frontend_path experiment_type.file_comp_system = comp_system_path await db.commit() await db.refresh(experiment_type) return CreateExperimentTypeResponse( id=experiment_type.id, name=experiment_type.name, description=experiment_type.description, ) except Exception as e: # Rollback if upload fails await db.rollback() raise HTTPException( 500, detail=f"Failed to create experiment type: {str(e)}", ) @router.put("/types", response_model=CreateExperimentTypeResponse) async def update_type_request( type_id: int = Form(), name: Optional[str] = Form(None), file_frontend: Optional[UploadFile] | None = File(None), file_comp_system: Optional[UploadFile] | None = File(None), description: Optional[str] = None, db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ) -> CreateExperimentTypeResponse: """Create a new experiment type with file uploads to MinIO (admin only)""" # Check authentication keycloak_id = payload.get("sub") if not keycloak_id: raise HTTPException(status_code=403, detail="Permission denied") # Ensure MinIO bucket exists await ensure_bucket_exists() frontend_path = None comp_path = None if file_frontend: frontend_path = await upload_file_to_minio( file=file_frontend, experiment_type_id=type_id ) if file_comp_system: comp_path = await upload_file_to_minio( file=file_comp_system, experiment_type_id=type_id ) # First create the experiment type in DB to get ID experiment_type = await update_experiment_type( db=db, id=type_id, name=name, description=description, file_frontend=frontend_path, # Temporary, will update file_comp_system=comp_path, # Temporary, will update ) return CreateExperimentTypeResponse( id=experiment_type.id, name=experiment_type.name, description=experiment_type.description, ) @router.get("/types/frontend") async def get_frontend_file( experiment_type_id: int = Query(), db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ): """Get the frontend HTML file""" # Get experiment type from database experiment_type = await get_experiment_type_by_id(db, experiment_type_id) if not experiment_type: raise HTTPException(status_code=404, detail="Experiment type not found") if not experiment_type.file_frontend: raise HTTPException(status_code=404, detail="Frontend file not found") # Get file from MinIO file_response = await get_file_from_minio(experiment_type.file_frontend) # Return file as streaming response with correct content type return StreamingResponse( file_response, media_type="text/html", headers={ "Content-Disposition": f"inline; filename=frontend_{experiment_type_id}.html" }, ) @router.get("/types/comp-system") async def get_comp_system_file( experiment_type_id: int, db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ): """Get the comp system Python file""" # Get experiment type from database experiment_type = await get_experiment_type_by_id(db, experiment_type_id) if not experiment_type: raise HTTPException(status_code=404, detail="Experiment type not found") if not experiment_type.file_comp_system: raise HTTPException(status_code=404, detail="Comp system file not found") # Get file from MinIO file_response = await get_file_from_minio(experiment_type.file_comp_system) # Return Python file for download (since it will be imported dynamically) return StreamingResponse( file_response, media_type="text/x-python", headers={ "Content-Disposition": f"attachment; filename=comp_system_{experiment_type_id}.py" }, ) # ============= EXPERIMENT ENDPOINTS ============= @router.post("", response_model=ExperimentData) async def create_experiment_request( create_data: CreateExperimentRequest, db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ) -> ExperimentData: """Create a new experiment""" keycloak_id = payload.get("sub") if not keycloak_id: raise HTTPException(403, "Permission denied") # Check if user has permission to create experiments in this team await check_team_permission( ["manage_experiments"], create_data.team_id, db, keycloak_id ) experiment = await create_experiment( db=db, team_id=create_data.team_id, experiment_type_id=create_data.experiment_type_id, name=create_data.name, description=create_data.description, ) return ExperimentData( id=experiment.id, team=TeamsShortListResponse( team_id=experiment.team.id, team_name=experiment.team.name ), name=experiment.name, description=experiment.description, created_at=experiment.created_at, experiment_type=ExperimentTypeList( id=experiment.experiment_type.id, name=experiment.experiment_type.name, description=experiment.experiment_type.description, ), instances_count=0, status=get_experiment_collective_status( list( map( lambda x: ( x.simulation_result.status.name if x.simulation_result else "DRAFT" ), experiment.instances, ) ) ), instance_preview=list( map( lambda inst: SimpleInstanceData( id=inst.id, instance_data={}, name=inst.name, qubits_needed=inst.qubits_needed, description=inst.description, ), experiment.instances[:6], ) ), ) @router.get("", response_model=ExperimentData) async def get_experiment_request( experiment_id: int = Query(), db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ) -> ExperimentData: """Get a single experiment's data (without instances)""" keycloak_id = payload.get("sub") if not keycloak_id: raise HTTPException(403, "Permission denied") experiment = await get_single_experiment(db, experiment_id) if not experiment: raise HTTPException(404, "Experiment not found") # Check if user has access to this experiment's team await check_team_permission([], experiment.team_id, db, keycloak_id) return ExperimentData( id=experiment.id, team=TeamsShortListResponse( team_id=experiment.team.id, team_name=experiment.team.name ), name=experiment.name, description=experiment.description, created_at=experiment.created_at, experiment_type=ExperimentTypeList( id=experiment.experiment_type.id, name=experiment.experiment_type.name, description=experiment.experiment_type.description, ), instances_count=len(experiment.instances), status=get_experiment_collective_status( list( map( lambda x: ( x.simulation_result.status.name if x.simulation_result else "DRAFT" ), experiment.instances, ) ) ), instance_preview=list( map( lambda inst: SimpleInstanceData( id=inst.id, instance_data="{}", name=inst.name, qubits_needed=inst.qubits_needed, description=inst.description, ), experiment.instances[:6], ) ), ) @router.put("", response_model=ExperimentData) async def update_experiment_request( update_data: UpdateExperimentRequest, db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ) -> ExperimentData: """Update experiment data (cannot update type)""" keycloak_id = payload.get("sub") if not keycloak_id: raise HTTPException(403, "Permission denied") experiment = await get_single_experiment(db, update_data.experiment_id) if not experiment: raise HTTPException(404, "Experiment not found") # Check if user has permission to update experiments in this team await check_team_permission( ["manage_experiments"], experiment.team_id, db, keycloak_id ) updated_experiment = await update_experiment( db=db, experiment_id=update_data.experiment_id, name=update_data.name, description=update_data.description, ) return ExperimentData( id=updated_experiment.id, team=TeamsShortListResponse( team_id=updated_experiment.team.id, team_name=updated_experiment.team.name ), name=updated_experiment.name, description=updated_experiment.description, created_at=updated_experiment.created_at, experiment_type=ExperimentTypeList( id=updated_experiment.experiment_type.id, name=updated_experiment.experiment_type.name, description=updated_experiment.experiment_type.description, ), instances_count=len(experiment.instances), status=get_experiment_collective_status( list( map( lambda x: ( x.simulation_result.status.name if x.simulation_result else "DRAFT" ), experiment.instances, ) ) ), instance_preview=list( map( lambda inst: SimpleInstanceData( id=inst.id, instance_data=json.dumps(inst.instance_data), name=inst.name, qubits_needed=inst.qubits_needed, description=inst.description, ), updated_experiment.instances[:6], ) ), ) @router.get("/user", response_model=ExperimentListResponse) async def get_user_experiments_request( page_num: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100), db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ) -> ExperimentListResponse: """Get all experiments accessible to the current user (via teams)""" keycloak_id = payload.get("sub") if not keycloak_id: raise HTTPException(403, "Permission denied") offset = (page_num - 1) * page_size experiments, total_count = await get_user_experiments( db=db, user_id=keycloak_id, offset=offset, limit=page_size ) result = [] for experiment in experiments: result.append( ExperimentData( id=experiment.id, team=TeamsShortListResponse( team_id=experiment.team.id, team_name=experiment.team.name ), name=experiment.name, description=experiment.description, created_at=experiment.created_at, experiment_type=ExperimentTypeList( id=experiment.experiment_type.id, name=experiment.experiment_type.name, description=experiment.experiment_type.description, ), instances_count=len(experiment.instances), status=get_experiment_collective_status( list( map( lambda x: ( x.simulation_result.status.name if x.simulation_result else "DRAFT" ), experiment.instances, ) ) ), instance_preview=list( map( lambda inst: SimpleInstanceData( id=inst.id, instance_data="{}", name=inst.name, qubits_needed=inst.qubits_needed, description=inst.description, ), experiment.instances[:6], ) ), ) ) return ExperimentListResponse( experiments=result, cur_page=page_num, total_experiments=total_count, page_size=page_size, ) @router.delete("") async def delete_experiment_request( experiment_id: int = Query(...), db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ): """Delete an experiment (cascade will delete instances)""" keycloak_id = payload.get("sub") if not keycloak_id: raise HTTPException(403, "Permission denied") experiment = await get_single_experiment(db, experiment_id) if not experiment: raise HTTPException(404, "Experiment not found") # Check if user has permission to delete experiments in this team await check_team_permission( ["manage_experiments"], experiment.team_id, db, keycloak_id ) deleted = await delete_experiment(db, experiment_id) if not deleted: raise HTTPException(404, "Experiment not found") return {"message": "Experiment deleted successfully"} # ============= INSTANCE ENDPOINTS ============= @router.post("/instance", response_model=SimpleInstanceData) async def create_instance_request( create_data: CreateInstanceRequest, db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ) -> SimpleInstanceData: """Add an instance to an experiment""" keycloak_id = payload.get("sub") if not keycloak_id: raise HTTPException(403, "Permission denied") experiment = await get_single_experiment(db, create_data.experiment_id) if not experiment: raise HTTPException(404, "Experiment not found") # Check if user has permission to manage experiments in this team await check_team_permission( ["manage_experiments"], experiment.team_id, db, keycloak_id ) instance = await create_instance( db=db, experiment_id=create_data.experiment_id, instance_data=create_data.instance_data, name=create_data.name, description=create_data.description, ) return SimpleInstanceData( id=instance.id, instance_data=json.dumps(create_data.instance_data), name=instance.name, qubits_needed=instance.qubits_needed, description=instance.description, ) @router.put("/instance", response_model=SimpleInstanceData) async def update_instance_request( update_data: UpdateInstanceRequest, db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ) -> SimpleInstanceData: """Update instance data (cannot update instance_data_id)""" keycloak_id = payload.get("sub") if not keycloak_id: raise HTTPException(403, "Permission denied") # Get instance to find its experiment instance = await get_single_instance(db, update_data.instance_id) if not instance: raise HTTPException(404, "Instance not found") experiment = await get_single_experiment(db, instance.experiment_id) if not experiment: raise HTTPException(404, "Experiment not found") # Check if user has permission to manage experiments in this team await check_team_permission( ["manage_experiments"], experiment.team_id, db, keycloak_id ) if update_data.qubits_needed is not None: await validate_instance_qubits( db, experiment.team_id, update_data.qubits_needed ) updated_instance = await update_instance( db=db, instance_id=update_data.instance_id, name=update_data.name, description=update_data.description, instance_data=update_data.instance_data, qubits_needed=update_data.qubits_needed, ) return SimpleInstanceData( id=updated_instance.id, instance_data=json.dumps(updated_instance.instance_data), name=updated_instance.name, qubits_needed=updated_instance.qubits_needed, description=updated_instance.description, ) @router.get("/instance", response_model=InstanceListResponse) async def get_experiment_instances_request( experiment_id: int = Query(), page_num: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100), db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ) -> InstanceListResponse: """Get all instances of an experiment""" keycloak_id = payload.get("sub") if not keycloak_id: raise HTTPException(403, "Permission denied") experiment = await get_single_experiment(db, experiment_id) if not experiment: raise HTTPException(404, "Experiment not found") # Check if user has access to this experiment's team await check_team_permission([], experiment.team_id, db, keycloak_id) offset = (page_num - 1) * page_size instances, total_count = await get_experiment_instances( db=db, experiment_id=experiment_id, offset=offset, limit=page_size ) result = [] for instance in instances: result.append( InstanceData( instance_id=instance.id, instance_data=json.dumps(instance.instance_data), name=instance.name, qubits_needed=instance.qubits_needed, description=instance.description, simulation_result=SimulationResultData( id=instance.simulation_result.id, comp_system=ComputationalSystemShortData( system_id=instance.simulation_result.computational_system.id, system_name=instance.simulation_result.computational_system.system_name, ) if instance.simulation_result.computational_system else None, simulation_result=json.dumps( instance.simulation_result.simulation_result ), status=instance.simulation_result.status.name, started_at=instance.simulation_result.started_at, ended_at=instance.simulation_result.ended_at, ) if instance.simulation_result else None, ) ) return InstanceListResponse( instances=result, cur_page=page_num, total_instances=total_count, page_size=page_size, ) @router.delete("/instance") async def delete_instance_request( instance_id: int = Query(...), db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ): """Remove an instance from an experiment""" keycloak_id = payload.get("sub") if not keycloak_id: raise HTTPException(403, "Permission denied") # Get instance to find its experiment instance = await get_single_instance(db, instance_id) if not instance: raise HTTPException(404, "Instance not found") experiment = await get_single_experiment(db, instance.experiment_id) if not experiment: raise HTTPException(404, "Experiment not found") # Check if user has permission to manage experiments in this team await check_team_permission( ["manage_experiments"], experiment.team_id, db, keycloak_id ) deleted = await delete_instance(db, instance_id) if not deleted: raise HTTPException(404, "Instance not found") return {"message": "Instance deleted successfully"} @router.get("/instance/id", response_model=InstanceData) async def get_instance_request( instance_id: int = Query(), db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ) -> InstanceData: """Get a single instance's data by ID""" keycloak_id = payload.get("sub") if not keycloak_id: raise HTTPException(403, "Permission denied") # Get instance with all relationships instance = await get_single_instance(db, instance_id) if not instance: raise HTTPException(404, "Instance not found") experiment = await get_single_experiment(db, instance.experiment_id) if not experiment: raise HTTPException(404, "Experiment not found") # Check if user has access to this experiment's team await check_team_permission([], experiment.team_id, db, keycloak_id) return InstanceData( instance_id=instance.id, instance_data=json.dumps(instance.instance_data), name=instance.name, description=instance.description, qubits_needed=instance.qubits_needed, simulation_result=SimulationResultData( id=instance.simulation_result.id, comp_system=ComputationalSystemShortData( system_id=instance.simulation_result.computational_system.id, system_name=instance.simulation_result.computational_system.system_name, ) if instance.simulation_result.computational_system else None, simulation_result=json.dumps(instance.simulation_result.simulation_result), status=instance.simulation_result.status.name, started_at=instance.simulation_result.started_at, ended_at=instance.simulation_result.ended_at, ) if instance.simulation_result else None, ) # ============= SIMULATION ENDPOINTS ============= @router.post("/start") async def start_experiment_request( start_data: StartExperimentRequest, db: AsyncSession = Depends(get_db), payload: dict = Depends(get_current_token_payload), ): """Start an experiment (create simulation result for an instance)""" keycloak_id = payload.get("sub") if not keycloak_id: raise HTTPException(403, "Permission denied") # Get instance to find its experiment experiment = await get_single_experiment(db, start_data.experiment_id) if not experiment: raise HTTPException(404, "Experiment not found") if not experiment.instances: raise HTTPException(400, "No instances") for task in experiment.instances: if task.simulation_result: # If simulation result exists, check its status current_status = task.simulation_result.status.name if current_status != "DRAFT": raise HTTPException( 400, f"Cannot start experiment. Task {task.id} has status '{current_status}'. " f"Only tasks with 'draft' status can be started.", ) # Check if user has permission to run experiments in this team await check_team_permission( ["run_experiments"], experiment.team_id, db, keycloak_id ) # TODO: add the starting rabbitMQ logic max_qubits = max(i.qubits_given for i in experiment.team.team_systems) for task in experiment.instances: if task.qubits_needed > max_qubits or task.qubits_needed <= 0: raise HTTPException( 400, f"Cannot start experiment. Instance {task.id} requires {task.qubits_needed} qubits, " f"but the maximum available in team's systems is {max_qubits} qubits.", ) await bind_queues_to_team_exchange(experiment.team_id, max_qubits) for task in sorted( experiment.instances, key=lambda x: x.qubits_needed, reverse=True ): try: # Publish task to queue await publish_task_to_team(task) # Create simulation result with IN_QUEUE status # Pass None for comp_system_id since it's not assigned yet simulation_result = await set_simulation_result( db=db, comp_system_id=None, # No system assigned yet instance_id=task.id, status_name="IN QUEUE", started_at=datetime.now(), ended_at=None, simulation_result_data={}, ) except Exception as e: # Create failed simulation result await set_simulation_result( db=db, comp_system_id=None, instance_id=task.id, status_name="ERROR", started_at=datetime.now(), ended_at=datetime.now(), simulation_result_data={"error": str(e)}, ) raise HTTPException(500, f"Failed to publish task {task.id}: {str(e)}") return HTTP_200_OK