From ea4d91f3bd453fcb3a1b8df2d9cb9acc9bdcce5b Mon Sep 17 00:00:00 2001 From: DeOwl Date: Wed, 6 May 2026 20:05:41 +0300 Subject: [PATCH] added experiment api --- .../src/api_endpoint/experiment_api.py | 569 ++++++++++++++++++ .../src/api_endpoint/machine_api.py | 3 + dockerfile_build/src/app.py | 2 + dockerfile_build/src/crud/experiment_crud.py | 453 ++++++++++++++ .../src/rest_models/experiment_models.py | 171 ++++++ dockerfile_build/src/sql_models/models.py | 3 - 6 files changed, 1198 insertions(+), 3 deletions(-) create mode 100644 dockerfile_build/src/api_endpoint/experiment_api.py create mode 100644 dockerfile_build/src/crud/experiment_crud.py create mode 100644 dockerfile_build/src/rest_models/experiment_models.py diff --git a/dockerfile_build/src/api_endpoint/experiment_api.py b/dockerfile_build/src/api_endpoint/experiment_api.py new file mode 100644 index 0000000..0e25ce3 --- /dev/null +++ b/dockerfile_build/src/api_endpoint/experiment_api.py @@ -0,0 +1,569 @@ +from datetime import datetime +from typing import List + +from connections.db import get_db +from connections.keycloak import ( + get_current_token_payload, +) +from crud.experiment_crud import ( + create_experiment, + create_experiment_type, + create_instance, + delete_experiment, + delete_instance, + get_all_experiment_types, + get_experiment_instances, + get_single_experiment, + get_single_instance, + get_user_experiments, + set_simulation_result, + update_experiment, + update_instance, +) +from crud.team_crud import check_team_permission +from fastapi import Depends, Query +from fastapi.routing import APIRouter +from rest_models.experiment_models import ( + CreateExperimentRequest, + CreateExperimentResponse, + CreateExperimentTypeRequest, + CreateExperimentTypeResponse, + CreateInstanceRequest, + CreateInstanceResponse, + ExperimentData, + ExperimentListResponse, + ExperimentTypeData, + InstanceData, + InstanceListResponse, + StartExperimentRequest, + StartExperimentResponse, + UpdateExperimentRequest, + UpdateExperimentResponse, + UpdateInstanceRequest, + UpdateInstanceResponse, +) +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.exceptions import HTTPException + +router = APIRouter(tags=["Experiments"]) + + +# ============= EXPERIMENT TYPE ENDPOINTS ============= + + +@router.get("/types", response_model=List[ExperimentTypeData]) +async def get_experiment_types_request( + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), +) -> List[ExperimentTypeData]: + """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 [ + ExperimentTypeData( + id=exp_type.id, + name=exp_type.name, + description=exp_type.description, + file_frontend=exp_type.file_frontend, + server_path=exp_type.server_path, + file_comp_system=exp_type.file_comp_system, + ) + for exp_type in experiment_types + ] + + +@router.post("/types", response_model=CreateExperimentTypeResponse) +async def create_experiment_type_request( + create_data: CreateExperimentTypeRequest, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), +) -> CreateExperimentTypeResponse: + """Create a new experiment type (admin only)""" + keycloak_id = payload.get("sub") + + if not keycloak_id: + raise HTTPException(403, "Permission denied") + + # TODO: Add admin permission check here if needed + + experiment_type = await create_experiment_type( + db=db, + name=create_data.name, + file_frontend=create_data.file_frontend, + server_path=create_data.server_path, + file_comp_system=create_data.file_comp_system, + description=create_data.description, + ) + + return CreateExperimentTypeResponse( + id=experiment_type.id, + name=experiment_type.name, + description=experiment_type.description, + file_frontend=experiment_type.file_frontend, + server_path=experiment_type.server_path, + file_comp_system=experiment_type.file_comp_system, + ) + + +# ============= EXPERIMENT ENDPOINTS ============= + + +@router.post("", response_model=CreateExperimentResponse) +async def create_experiment_request( + create_data: CreateExperimentRequest, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), +) -> CreateExperimentResponse: + """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 CreateExperimentResponse( + id=experiment.id, + team_id=experiment.team_id, + experiment_type_id=experiment.experiment_type_id, + name=experiment.name, + description=experiment.description, + created_at=experiment.created_at, + ) + + +@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_id=experiment.team_id, + team_name=experiment.team.name if experiment.team else None, + experiment_type_id=experiment.experiment_type_id, + experiment_type_name=experiment.experiment_type.name + if experiment.experiment_type + else None, + name=experiment.name, + description=experiment.description, + created_at=experiment.created_at, + instances_count=len(experiment.instances) if experiment.instances else 0, + ) + + +@router.put("", response_model=UpdateExperimentResponse) +async def update_experiment_request( + update_data: UpdateExperimentRequest, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), +) -> UpdateExperimentResponse: + """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 UpdateExperimentResponse( + id=updated_experiment.id, + team_id=updated_experiment.team_id, + experiment_type_id=updated_experiment.experiment_type_id, + name=updated_experiment.name, + description=updated_experiment.description, + created_at=updated_experiment.created_at, + ) + + +@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_id=experiment.team_id, + team_name=experiment.team.name if experiment.team else None, + experiment_type_id=experiment.experiment_type_id, + experiment_type_name=experiment.experiment_type.name + if experiment.experiment_type + else None, + name=experiment.name, + description=experiment.description, + created_at=experiment.created_at, + instances_count=len(experiment.instances) + if experiment.instances + else 0, + ) + ) + + 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=CreateInstanceResponse) +async def create_instance_request( + create_data: CreateInstanceRequest, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), +) -> CreateInstanceResponse: + """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_id=create_data.instance_data_id, + name=create_data.name, + description=create_data.description, + ) + + return CreateInstanceResponse( + id=instance.id, + experiment_id=instance.experiment_id, + instance_data_id=instance.instance_data_id, + name=instance.name, + description=instance.description, + simulation_result_id=instance.simulation_result_id, + ) + + +@router.put("/instance", response_model=UpdateInstanceResponse) +async def update_instance_request( + update_data: UpdateInstanceRequest, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), +) -> UpdateInstanceResponse: + """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 + ) + + updated_instance = await update_instance( + db=db, + instance_id=update_data.instance_id, + name=update_data.name, + description=update_data.description, + ) + + return UpdateInstanceResponse( + id=updated_instance.id, + experiment_id=updated_instance.experiment_id, + instance_data_id=updated_instance.instance_data_id, + name=updated_instance.name, + description=updated_instance.description, + simulation_result_id=updated_instance.simulation_result_id, + ) + + +@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( + id=instance.id, + experiment_id=instance.experiment_id, + instance_data_id=instance.instance_data_id, + name=instance.name, + description=instance.description, + simulation_result_id=instance.simulation_result_id, + simulation_status=instance.simulation_result.status.name + if instance.simulation_result and instance.simulation_result.status + else None, + simulation_started_at=instance.simulation_result.started_at + if instance.simulation_result + else None, + simulation_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"} + + +# ============= SIMULATION ENDPOINTS ============= + + +@router.post("/start", response_model=StartExperimentResponse) +async def start_experiment_request( + start_data: StartExperimentRequest, + db: AsyncSession = Depends(get_db), + payload: dict = Depends(get_current_token_payload), +) -> StartExperimentResponse: + """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 + instance = await get_single_instance(db, start_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 run experiments in this team + await check_team_permission( + ["run_experiments"], experiment.team_id, db, keycloak_id + ) + + # Create or update simulation result + simulation_result = await set_simulation_result( + db=db, + comp_system_id=start_data.comp_system_id, + simulation_result_id=start_data.simulation_result_id, + status_name="PENDING", # Start with PENDING status + started_at=datetime.now(), + ) + + # Link simulation result to instance if not already linked + if not instance.simulation_result_id: + await update_instance( + db=db, + instance_id=instance.id, + simulation_result_id=simulation_result.id, + ) + + return StartExperimentResponse( + instance_id=instance.id, + simulation_result_id=simulation_result.id, + status="PENDING", + started_at=simulation_result.started_at, + ) + + +@router.get("/instance", 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( + id=instance.id, + experiment_id=instance.experiment_id, + instance_data_id=instance.instance_data_id, + name=instance.name, + description=instance.description, + simulation_result_id=instance.simulation_result_id, + simulation_status=instance.simulation_result.status.name + if instance.simulation_result and instance.simulation_result.status + else None, + simulation_started_at=instance.simulation_result.started_at + if instance.simulation_result + else None, + simulation_ended_at=instance.simulation_result.ended_at + if instance.simulation_result + else None, + ) diff --git a/dockerfile_build/src/api_endpoint/machine_api.py b/dockerfile_build/src/api_endpoint/machine_api.py index b055cff..b2e44a6 100644 --- a/dockerfile_build/src/api_endpoint/machine_api.py +++ b/dockerfile_build/src/api_endpoint/machine_api.py @@ -232,6 +232,9 @@ async def get_team_systems_request( created_at = ts.created_at break + if created_at == 0: + raise HTTPException(404, "Team data not found") + result.append( SystemWithTeamResponse( system=ComputationalSystemData( diff --git a/dockerfile_build/src/app.py b/dockerfile_build/src/app.py index c6e95d9..e584fa6 100755 --- a/dockerfile_build/src/app.py +++ b/dockerfile_build/src/app.py @@ -1,6 +1,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from api_endpoint.experiment_api import router as experiment_router from api_endpoint.health_api import router as health_router from api_endpoint.machine_api import router as machine_router from api_endpoint.teams_api import router as team_router @@ -52,3 +53,4 @@ app.include_router(health_router, prefix="", tags=["Health"]) app.include_router(user_router, prefix="/user", tags=["User"]) app.include_router(team_router, prefix="/team", tags=["Team"]) app.include_router(machine_router, prefix="/machine", tags=["Machine"]) +app.include_router(experiment_router, prefix="/experiment", tags=["Experiment"]) diff --git a/dockerfile_build/src/crud/experiment_crud.py b/dockerfile_build/src/crud/experiment_crud.py new file mode 100644 index 0000000..8bb9b69 --- /dev/null +++ b/dockerfile_build/src/crud/experiment_crud.py @@ -0,0 +1,453 @@ +from datetime import datetime +from typing import List, Optional, Tuple + +from crud.team_crud import get_user_teams +from sql_models.models import ( + Experiment, + ExperimentType, + Instance, + SimulationResult, + SimulationStatus, + Team, +) +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from sqlalchemy.sql.expression import func +from starlette.exceptions import HTTPException + +# ============= HELPER FUNCTIONS ============= + + +async def get_simulation_status_by_name( + db: AsyncSession, status_name: str +) -> SimulationStatus: + """Get simulation status by name, raise exception if not found""" + result = await db.execute( + select(SimulationStatus).where(SimulationStatus.name == status_name) + ) + status = result.scalar_one_or_none() + + if not status: + raise HTTPException( + status_code=400, + detail=f"Invalid simulation status name: {status_name}. Valid statuses: PENDING, RUNNING, COMPLETED, FAILED", + ) + return status + + +# ============= EXPERIMENT TYPE CRUD ============= + + +async def get_all_experiment_types( + db: AsyncSession, +) -> List[ExperimentType]: + """Get all existing experiment types""" + result = await db.execute(select(ExperimentType).order_by(ExperimentType.name)) + return list(result.scalars().all()) + + +async def create_experiment_type( + db: AsyncSession, + name: str, + file_frontend: str, + server_path: str, + file_comp_system: str, + description: Optional[str] = None, +) -> ExperimentType: + """Create a new experiment type""" + # Check if experiment type with this name already exists + existing_result = await db.execute( + select(ExperimentType).where(ExperimentType.name == name) + ) + existing = existing_result.scalar_one_or_none() + + if existing: + raise HTTPException( + status_code=409, detail=f"Experiment type with name '{name}' already exists" + ) + + # Create new experiment type + experiment_type = ExperimentType( + name=name, + description=description, + file_frontend=file_frontend, + server_path=server_path, + file_comp_system=file_comp_system, + ) + db.add(experiment_type) + await db.commit() + await db.refresh(experiment_type) + + return experiment_type + + +# ============= EXPERIMENT CRUD ============= + + +async def create_experiment( + db: AsyncSession, + team_id: int, + experiment_type_id: int, + name: str, + description: Optional[str] = None, +) -> Experiment: + """Create an experiment of a specific type""" + + team_result = await db.execute(select(Team).where(Team.id == team_id)) + team = team_result.scalar_one_or_none() + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + # Verify experiment type exists + exp_type_result = await db.execute( + select(ExperimentType).where(ExperimentType.id == experiment_type_id) + ) + experiment_type = exp_type_result.scalar_one_or_none() + if not experiment_type: + raise HTTPException(status_code=404, detail="Experiment type not found") + + # Create experiment + experiment = Experiment( + team_id=team_id, + experiment_type_id=experiment_type_id, + name=name, + description=description, + created_at=datetime.now(), + ) + db.add(experiment) + await db.commit() + await db.refresh(experiment, attribute_names=["team", "experiment_type"]) + + return experiment + + +async def update_experiment( + db: AsyncSession, + experiment_id: int, + name: Optional[str] = None, + description: Optional[str] = None, +) -> Experiment: + """Update experiment data (cannot update type)""" + result = await db.execute( + select(Experiment) + .where(Experiment.id == experiment_id) + .options( + selectinload(Experiment.team), selectinload(Experiment.experiment_type) + ) + ) + experiment = result.scalar_one_or_none() + + if not experiment: + raise HTTPException(status_code=404, detail="Experiment not found") + + if name is not None: + experiment.name = name + + if description is not None: + experiment.description = description + + await db.commit() + await db.refresh(experiment, attribute_names=["team", "experiment_type"]) + + return experiment + + +async def get_user_experiments( + db: AsyncSession, + user_id: str, + offset: int = 0, + limit: int = 256, +) -> Tuple[List[Experiment], int]: + """Get paginated experiments of user (via user's teams)""" + # Get user's team IDs first + user_teams_result, _ = await get_user_teams(db, user_id, 0, 255) + team_ids = [row.id for row in user_teams_result] + + if not team_ids: + return [], 0 + + # Query experiments for those teams + query = select(Experiment).where(Experiment.team_id.in_(team_ids)) + + # Get paginated experiments + result = await db.execute( + query.options( + selectinload(Experiment.team), + selectinload(Experiment.experiment_type), + selectinload(Experiment.instances), + ) + .order_by(Experiment.created_at.desc()) + .offset(offset) + .limit(limit) + ) + experiments = list(result.scalars().all()) + + # Get total count + count_query = ( + select(func.count()) + .select_from(Experiment) + .where(Experiment.team_id.in_(team_ids)) + ) + count_result = await db.execute(count_query) + total_count = count_result.scalar() + + return experiments, total_count if total_count else 0 + + +# Helper function to get single experiment (add to experiment_crud.py) +async def get_single_experiment( + db: AsyncSession, + experiment_id: int, +) -> Optional[Experiment]: + """Get a single experiment with all relationships loaded""" + result = await db.execute( + select(Experiment) + .where(Experiment.id == experiment_id) + .options( + selectinload(Experiment.team), + selectinload(Experiment.experiment_type), + ) + ) + return result.scalar_one_or_none() + + +async def delete_experiment( + db: AsyncSession, + experiment_id: int, +) -> bool: + """Delete an experiment (cascade will delete instances)""" + result = await db.execute(select(Experiment).where(Experiment.id == experiment_id)) + experiment = result.scalar_one_or_none() + + if not experiment: + return False + + await db.delete(experiment) + await db.commit() + + return True + + +async def create_instance( + db: AsyncSession, + experiment_id: int, + instance_data_id: int, + name: str, + description: Optional[str] = None, +) -> Instance: + """Create an instance for an experiment""" + # Verify experiment exists + experiment_result = await db.execute( + select(Experiment).where(Experiment.id == experiment_id) + ) + experiment = experiment_result.scalar_one_or_none() + if not experiment: + raise HTTPException(status_code=404, detail="Experiment not found") + + # Create instance + instance = Instance( + experiment_id=experiment_id, + instance_data_id=instance_data_id, + name=name, + description=description, + ) + db.add(instance) + await db.commit() + await db.refresh(instance, attribute_names=["experiment", "simulation_result"]) + + return instance + + +async def update_instance( + db: AsyncSession, + instance_id: int, + name: Optional[str] = None, + description: Optional[str] = None, + simulation_result_id: Optional[int] = None, +) -> Instance: + """Update instance data (cannot update instance_data_id)""" + result = await db.execute( + select(Instance) + .where(Instance.id == instance_id) + .options( + selectinload(Instance.experiment), selectinload(Instance.simulation_result) + ) + ) + instance = result.scalar_one_or_none() + + if not instance: + raise HTTPException(status_code=404, detail="Instance not found") + + if name is not None: + instance.name = name + + if description is not None: + instance.description = description + + if simulation_result_id is not None: + # Verify simulation result exists + sim_result_result = await db.execute( + select(SimulationResult).where(SimulationResult.id == simulation_result_id) + ) + if not sim_result_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Simulation result not found") + instance.simulation_result_id = simulation_result_id + + await db.commit() + await db.refresh(instance, attribute_names=["experiment", "simulation_result"]) + + return instance + + +async def get_experiment_instances( + db: AsyncSession, + experiment_id: int, + offset: int = 0, + limit: int = 256, +) -> Tuple[List[Instance], int]: + """Get paginated instances of an experiment""" + # Verify experiment exists + experiment_result = await db.execute( + select(Experiment).where(Experiment.id == experiment_id) + ) + if not experiment_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Experiment not found") + + # Query instances + query = select(Instance).where(Instance.experiment_id == experiment_id) + + result = await db.execute( + query.options( + selectinload(Instance.experiment), + selectinload(Instance.simulation_result).selectinload( + SimulationResult.status + ), + ) + .order_by(Instance.id.desc()) + .offset(offset) + .limit(limit) + ) + instances = list(result.scalars().all()) + + # Get total count + count_query = ( + select(func.count()) + .select_from(Instance) + .where(Instance.experiment_id == experiment_id) + ) + count_result = await db.execute(count_query) + total_count = count_result.scalar() + + return instances, total_count if total_count else 0 + + +async def get_single_instance( + db: AsyncSession, + instance_id: int, +) -> Optional[Instance]: + """Get a single instance's data with all relationships loaded""" + result = await db.execute( + select(Instance) + .where(Instance.id == instance_id) + .options( + selectinload(Instance.experiment).selectinload(Experiment.experiment_type), + selectinload(Instance.simulation_result).selectinload( + SimulationResult.status + ), + selectinload(Instance.simulation_result).selectinload( + SimulationResult.computational_system + ), + ) + ) + return result.scalar_one_or_none() + + +async def delete_instance( + db: AsyncSession, + instance_id: int, +) -> bool: + """Delete an instance from an experiment""" + result = await db.execute(select(Instance).where(Instance.id == instance_id)) + instance = result.scalar_one_or_none() + + if not instance: + return False + + await db.delete(instance) + await db.commit() + + return True + + +# ============= SIMULATION RESULT CRUD ============= + + +async def set_simulation_result( + db: AsyncSession, + comp_system_id: int, + simulation_result_id: int, + status_name: str, + started_at: Optional[datetime] = None, + ended_at: Optional[datetime] = None, +) -> SimulationResult: + """Create or update simulation result""" + # Verify computational system exists + from sql_models.models import ComputationalSystem + + system_result = await db.execute( + select(ComputationalSystem).where(ComputationalSystem.id == comp_system_id) + ) + if not system_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Computational system not found") + + # Get status + status = await get_simulation_status_by_name(db, status_name) + + # Check if simulation result already exists + existing_result = await db.execute( + select(SimulationResult).where( + SimulationResult.simulation_result_id == simulation_result_id + ) + ) + simulation_result = existing_result.scalar_one_or_none() + + if simulation_result: + # Update existing + simulation_result.comp_system_id = comp_system_id + simulation_result.status_id = status.id + simulation_result.started_at = started_at or simulation_result.started_at + simulation_result.ended_at = ended_at + else: + # Create new + simulation_result = SimulationResult( + comp_system_id=comp_system_id, + simulation_result_id=simulation_result_id, + status_id=status.id, + started_at=started_at or datetime.now(), + ended_at=ended_at, + ) + db.add(simulation_result) + + await db.commit() + await db.refresh( + simulation_result, attribute_names=["computational_system", "status"] + ) + + return simulation_result + + +async def get_simulation_result_by_id( + db: AsyncSession, + simulation_result_id: int, +) -> Optional[SimulationResult]: + """Get a simulation result by its external ID""" + result = await db.execute( + select(SimulationResult) + .where(SimulationResult.simulation_result_id == simulation_result_id) + .options( + selectinload(SimulationResult.computational_system), + selectinload(SimulationResult.status), + ) + ) + return result.scalar_one_or_none() diff --git a/dockerfile_build/src/rest_models/experiment_models.py b/dockerfile_build/src/rest_models/experiment_models.py new file mode 100644 index 0000000..f37dcaa --- /dev/null +++ b/dockerfile_build/src/rest_models/experiment_models.py @@ -0,0 +1,171 @@ +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel + +# ============= EXPERIMENT TYPE MODELS ============= + + +class ExperimentTypeData(BaseModel): + id: int + name: str + description: Optional[str] = None + file_frontend: str + server_path: str + file_comp_system: str + + +class CreateExperimentTypeRequest(BaseModel): + name: str + file_frontend: str + server_path: str + file_comp_system: str + description: Optional[str] = None + + +class CreateExperimentTypeResponse(BaseModel): + id: int + name: str + description: Optional[str] = None + file_frontend: str + server_path: str + file_comp_system: str + + +# ============= EXPERIMENT MODELS ============= + + +class CreateExperimentRequest(BaseModel): + team_id: int + experiment_type_id: int + name: str + description: Optional[str] = None + + +class CreateExperimentResponse(BaseModel): + id: int + team_id: int + experiment_type_id: int + name: str + description: Optional[str] = None + created_at: datetime + + +class UpdateExperimentRequest(BaseModel): + experiment_id: int + name: Optional[str] = None + description: Optional[str] = None + + +class UpdateExperimentResponse(BaseModel): + id: int + team_id: int + experiment_type_id: int + name: str + description: Optional[str] = None + created_at: datetime + + +class ExperimentData(BaseModel): + id: int + team_id: int + team_name: Optional[str] = None + experiment_type_id: int + experiment_type_name: Optional[str] = None + name: str + description: Optional[str] = None + created_at: datetime + instances_count: int = 0 + + +class ExperimentListResponse(BaseModel): + experiments: List[ExperimentData] + cur_page: int + total_experiments: int + page_size: int + + +# ============= INSTANCE MODELS ============= + + +class CreateInstanceRequest(BaseModel): + experiment_id: int + instance_data_id: int + name: str + description: Optional[str] = None + + +class CreateInstanceResponse(BaseModel): + id: int + experiment_id: int + instance_data_id: int + name: str + description: Optional[str] = None + simulation_result_id: Optional[int] = None + + +class UpdateInstanceRequest(BaseModel): + instance_id: int + name: Optional[str] = None + description: Optional[str] = None + + +class UpdateInstanceResponse(BaseModel): + id: int + experiment_id: int + instance_data_id: int + name: str + description: Optional[str] = None + simulation_result_id: Optional[int] = None + + +class InstanceData(BaseModel): + id: int + experiment_id: int + instance_data_id: int + name: str + description: Optional[str] = None + simulation_result_id: Optional[int] = None + simulation_status: Optional[str] = None + simulation_started_at: Optional[datetime] = None + simulation_ended_at: Optional[datetime] = None + + +class InstanceListResponse(BaseModel): + instances: List[InstanceData] + cur_page: int + total_instances: int + page_size: int + + +# ============= SIMULATION MODELS ============= + + +class StartExperimentRequest(BaseModel): + instance_id: int + comp_system_id: int + simulation_result_id: int + + +class StartExperimentResponse(BaseModel): + instance_id: int + simulation_result_id: int + status: str + started_at: datetime + + +class SetSimulationResultRequest(BaseModel): + comp_system_id: int + simulation_result_id: int + status_name: str + started_at: Optional[datetime] = None + ended_at: Optional[datetime] = None + + +class SimulationResultData(BaseModel): + id: int + simulation_result_id: int + comp_system_id: int + status: str + started_at: datetime + ended_at: Optional[datetime] = None diff --git a/dockerfile_build/src/sql_models/models.py b/dockerfile_build/src/sql_models/models.py index b4a4376..bfe0eaa 100644 --- a/dockerfile_build/src/sql_models/models.py +++ b/dockerfile_build/src/sql_models/models.py @@ -202,9 +202,6 @@ class User(Base): ) -# Add these new classes before your existing User class - - class ExperimentType(Base): __tablename__ = "experiment_types"