diff --git a/app/db/model.py b/app/db/model.py index a1d7a9f0..017af5ea 100644 --- a/app/db/model.py +++ b/app/db/model.py @@ -1248,6 +1248,89 @@ class IonChannelModelToEModel(Base): ) +class IonChannelModelToIonChannelModelSimulationCampaign(Base): + __tablename__ = "ion_channel_model__ion_channel_model_simulation_campaign" + + ion_channel_model_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey(f"{EntityType.ion_channel_model}.id", ondelete="CASCADE"), primary_key=True + ) + ion_channel_model_simulation_campaign_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey(f"{EntityType.ion_channel_model_simulation_campaign}.id", ondelete="CASCADE"), + primary_key=True, + ) + + +class SimulationCampaignBase( + NameDescriptionVectorMixin, + Entity, +): + """Represents a simulation campaign entity in the database. + + A simulation campaign represents the specification of a set of simulations. + + it has an asset which is the simulation campaign configuration file. + """ + + __abstract__ = True + + @declared_attr + @classmethod + def simulations(cls): + return relationship( + "Simulation", + uselist=True, + back_populates="simulation_campaign", + foreign_keys="Simulation.simulation_campaign_id", + ) + + @declared_attr + @classmethod + def scan_parameters(cls) -> Mapped[JSON_DICT]: + return mapped_column( + default={}, + nullable=False, + server_default="{}", + ) + + __mapper_args__ = { # noqa: RUF012 + "polymorphic_on": type, + } + + +class IonChannelModelSimulationCampaign( + SimulationCampaignBase, +): + """Represents an ion channel model simulation campaign entity in the database. + + An ion channel model simulation campaign represents the specification of a set of + ion channel model simulation tasks. + + It has an asset which is the ion channel model simulation campaign configuration file. + + Attributes: + id (uuid.UUID): Primary key for the ion channel model simulation campaign, + referencing the entity ID. + """ + + __tablename__ = EntityType.ion_channel_model_simulation_campaign.value + id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entity.id"), primary_key=True) + + ion_channel_models: Mapped[list["IonChannelModel"]] = relationship( + "IonChannelModel", + primaryjoin=( + "IonChannelModelSimulationCampaign.id == " + "IonChannelModelToIonChannelModelSimulationCampaign." + "ion_channel_model_simulation_campaign_id" + ), + secondary="ion_channel_model__ion_channel_model_simulation_campaign", + ) + + __mapper_args__ = { # noqa: RUF012 + "polymorphic_identity": __tablename__, + "inherit_condition": id == Entity.id, + } + + class IonChannelRecordingToIonChannelModelingCampaign(Base): __tablename__ = "ion_channel_recording__ion_channel_modeling_campaign" @@ -1489,8 +1572,7 @@ class CellComposition(NameDescriptionVectorMixin, LocationMixin, SpeciesMixin, E class SimulationCampaign( - NameDescriptionVectorMixin, - Entity, + SimulationCampaignBase, ): """Represents a simulation campaign entity in the database. @@ -1507,17 +1589,6 @@ class SimulationCampaign( entity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entity.id"), index=True) - simulations = relationship( - "Simulation", - uselist=True, - back_populates="simulation_campaign", - foreign_keys="Simulation.simulation_campaign_id", - ) - scan_parameters: Mapped[JSON_DICT] = mapped_column( - default={}, - nullable=False, - server_default="{}", - ) __mapper_args__ = { # noqa: RUF012 "polymorphic_identity": __tablename__, "inherit_condition": id == Entity.id, @@ -1544,14 +1615,16 @@ class Simulation(Entity, NameDescriptionVectorMixin): __tablename__ = EntityType.simulation.value id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entity.id"), primary_key=True) simulation_campaign_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("simulation_campaign.id"), index=True + ForeignKey("simulation_campaign_base.id"), index=True ) - simulation_campaign: Mapped[SimulationCampaign] = relationship( - "SimulationCampaign", + simulation_campaign: Mapped[SimulationCampaignBase] = relationship( + "SimulationCampaignBase", uselist=False, foreign_keys=[simulation_campaign_id], ) - entity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entity.id"), index=True) + entity_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("entity.id"), index=True, nullable=True + ) entity: Mapped[Entity] = relationship( "Entity", uselist=False, diff --git a/app/db/types.py b/app/db/types.py index 1cd7a3f2..26575f66 100644 --- a/app/db/types.py +++ b/app/db/types.py @@ -116,6 +116,7 @@ class EntityType(StrEnum): experimental_synapses_per_connection = auto() external_url = auto() ion_channel_model = auto() + ion_channel_model_simulation_campaign = auto() ion_channel_modeling_campaign = auto() ion_channel_modeling_config = auto() ion_channel_recording = auto() @@ -785,6 +786,22 @@ class LabelRequirements(BaseModel): ) ], }, + EntityType.ion_channel_model_simulation_campaign: { + AssetLabel.campaign_generation_config: [ + LabelRequirements( + content_type=ContentType.json, + is_directory=False, + description="Campaign configuration.", + ) + ], + AssetLabel.campaign_summary: [ + LabelRequirements( + content_type=ContentType.json, + is_directory=False, + description="Summary of generated campaign listing all created simulation configs.", + ) + ], + }, EntityType.simulation_campaign: { AssetLabel.campaign_generation_config: [ LabelRequirements( diff --git a/app/filters/ion_channel_model_simulation_campaign.py b/app/filters/ion_channel_model_simulation_campaign.py new file mode 100644 index 00000000..0733e670 --- /dev/null +++ b/app/filters/ion_channel_model_simulation_campaign.py @@ -0,0 +1,30 @@ +from typing import Annotated + +from fastapi_filter import with_prefix + +from app.db.model import IonChannelModelSimulationCampaign +from app.dependencies.filter import FilterDepends +from app.filters.base import CustomFilter +from app.filters.common import ILikeSearchFilterMixin, NameFilterMixin +from app.filters.entity import EntityFilterMixin +from app.filters.simulation import NestedSimulationFilter + + +class IonChannelModelSimulationCampaignFilter( + CustomFilter, EntityFilterMixin, NameFilterMixin, ILikeSearchFilterMixin +): + simulation: Annotated[ + NestedSimulationFilter | None, + FilterDepends(with_prefix("simulation", NestedSimulationFilter)), + ] = None + + order_by: list[str] = ["-creation_date"] # noqa: RUF012 + + class Constants(CustomFilter.Constants): + model = IonChannelModelSimulationCampaign + ordering_model_fields = ["creation_date", "update_date", "name"] # noqa: RUF012 + + +IonChannelModelSimulationCampaignFilterDep = Annotated[ + IonChannelModelSimulationCampaignFilter, FilterDepends(IonChannelModelSimulationCampaignFilter) +] diff --git a/app/routers/__init__.py b/app/routers/__init__.py index a62c2b03..91c545df 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -40,6 +40,7 @@ external_url, ion_channel, ion_channel_model, + ion_channel_model_simulation_campaign, ion_channel_modeling_campaign, ion_channel_modeling_config, ion_channel_modeling_config_generation, @@ -124,6 +125,7 @@ external_url.router, ion_channel.router, ion_channel_model.router, + ion_channel_model_simulation_campaign.router, ion_channel_modeling_campaign.router, ion_channel_modeling_config.router, ion_channel_modeling_config_generation.router, diff --git a/app/routers/ion_channel_model_simulation_campaign.py b/app/routers/ion_channel_model_simulation_campaign.py new file mode 100644 index 00000000..2a3a35a9 --- /dev/null +++ b/app/routers/ion_channel_model_simulation_campaign.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter + +import app.service.ion_channel_model_simulation_campaign +from app.routers.admin import router as admin_router + +ROUTE = "ion-channel-model-simulation-campaign" +router = APIRouter(prefix=f"/{ROUTE}", tags=[ROUTE]) + +read_many = router.get("")(app.service.ion_channel_model_simulation_campaign.read_many) +read_one = router.get("/{id_}")(app.service.ion_channel_model_simulation_campaign.read_one) +create_one = router.post("")(app.service.ion_channel_model_simulation_campaign.create_one) +update_one = router.patch("/{id_}")(app.service.ion_channel_model_simulation_campaign.update_one) +delete_one = router.delete("/{id_}")(app.service.ion_channel_model_simulation_campaign.delete_one) + +admin_read_one = admin_router.get(f"/{ROUTE}/{{id_}}")( + app.service.ion_channel_model_simulation_campaign.admin_read_one +) +admin_update_one = admin_router.patch(f"/{ROUTE}/{{id_}}")( + app.service.ion_channel_model_simulation_campaign.admin_update_one +) diff --git a/app/schemas/ion_channel_model_simulation_campaign.py b/app/schemas/ion_channel_model_simulation_campaign.py new file mode 100644 index 00000000..9ff78821 --- /dev/null +++ b/app/schemas/ion_channel_model_simulation_campaign.py @@ -0,0 +1,54 @@ +from pydantic import BaseModel, ConfigDict + +from app.db.types import JSON_DICT +from app.schemas.agent import CreatedByUpdatedByMixin +from app.schemas.asset import AssetsMixin +from app.schemas.base import ( + AuthorizationMixin, + AuthorizationOptionalPublicMixin, + CreationMixin, + EntityTypeMixin, + IdentifiableMixin, + NameDescriptionMixin, +) +from app.schemas.contribution import ContributionReadWithoutEntityMixin +from app.schemas.ion_channel import NestedIonChannelRead +from app.schemas.utils import make_update_schema + + +class IonChannelModelSimulationCampaignBase(BaseModel, NameDescriptionMixin): + model_config = ConfigDict(from_attributes=True) + scan_parameters: JSON_DICT + + +class IonChannelModelSimulationCampaignCreate( + IonChannelModelSimulationCampaignBase, AuthorizationOptionalPublicMixin +): + pass + + +IonChannelModelSimulationCampaignUserUpdate = make_update_schema( + IonChannelModelSimulationCampaignCreate, "IonChannelModelSimulationCampaignUserUpdate" +) # pyright: ignore [reportInvalidTypeForm] +IonChannelModelSimulationCampaignAdminUpdate = make_update_schema( + IonChannelModelSimulationCampaignCreate, + "IonChannelModelSimulationCampaignAdminUpdate", + excluded_fields=set(), +) # pyright : ignore [reportInvalidTypeForm] + + +class NestedIonChannelModelSimulationCampaignRead( + IonChannelModelSimulationCampaignBase, EntityTypeMixin, IdentifiableMixin +): + pass + + +class IonChannelModelSimulationCampaignRead( + NestedIonChannelModelSimulationCampaignRead, + AssetsMixin, + CreatedByUpdatedByMixin, + CreationMixin, + AuthorizationMixin, + ContributionReadWithoutEntityMixin, +): + ion_channel_models: list[NestedIonChannelRead] diff --git a/app/service/ion_channel_model_simulation_campaign.py b/app/service/ion_channel_model_simulation_campaign.py new file mode 100644 index 00000000..39acc315 --- /dev/null +++ b/app/service/ion_channel_model_simulation_campaign.py @@ -0,0 +1,195 @@ +import uuid +from typing import TYPE_CHECKING + +import sqlalchemy as sa +from sqlalchemy.orm import aliased, joinedload, raiseload, selectinload + +from app.db.model import ( + Agent, + IonChannelModelSimulationCampaign, + Person, + Simulation, +) +from app.dependencies.auth import UserContextDep, UserContextWithProjectIdDep +from app.dependencies.common import ( + FacetsDep, + PaginationQuery, + SearchDep, +) +from app.dependencies.db import SessionDep +from app.filters.ion_channel_model_simulation_campaign import ( + IonChannelModelSimulationCampaignFilterDep, +) +from app.queries.common import ( + router_create_one, + router_read_many, + router_read_one, + router_update_one, + router_user_delete_one, +) +from app.queries.factory import query_params_factory +from app.schemas.ion_channel_model_simulation_campaign import ( + IonChannelModelSimulationCampaignAdminUpdate, + IonChannelModelSimulationCampaignCreate, + IonChannelModelSimulationCampaignRead, + IonChannelModelSimulationCampaignUserUpdate, +) +from app.schemas.routers import DeleteResponse +from app.schemas.types import ListResponse + +if TYPE_CHECKING: + from app.filters.base import Aliases + + +def _load(query: sa.Select): + return query.options( + joinedload(IonChannelModelSimulationCampaign.created_by), + joinedload(IonChannelModelSimulationCampaign.updated_by), + selectinload(IonChannelModelSimulationCampaign.assets), + selectinload(IonChannelModelSimulationCampaign.contributions), + selectinload(IonChannelModelSimulationCampaign.simulations), + selectinload(IonChannelModelSimulationCampaign.ion_channel_models), + raiseload("*"), + ) + + +def read_one( + user_context: UserContextDep, + db: SessionDep, + id_: uuid.UUID, +) -> IonChannelModelSimulationCampaignRead: + return router_read_one( + db=db, + id_=id_, + db_model_class=IonChannelModelSimulationCampaign, + user_context=user_context, + response_schema_class=IonChannelModelSimulationCampaignRead, + apply_operations=_load, + ) + + +def admin_read_one( + db: SessionDep, + id_: uuid.UUID, +) -> IonChannelModelSimulationCampaignRead: + return router_read_one( + db=db, + id_=id_, + db_model_class=IonChannelModelSimulationCampaign, + user_context=None, + response_schema_class=IonChannelModelSimulationCampaignRead, + apply_operations=_load, + ) + + +def create_one( + db: SessionDep, + json_model: IonChannelModelSimulationCampaignCreate, + user_context: UserContextWithProjectIdDep, +) -> IonChannelModelSimulationCampaignRead: + return router_create_one( + db=db, + json_model=json_model, + user_context=user_context, + db_model_class=IonChannelModelSimulationCampaign, + response_schema_class=IonChannelModelSimulationCampaignRead, + apply_operations=_load, + ) + + +def update_one( + user_context: UserContextDep, + db: SessionDep, + id_: uuid.UUID, + json_model: IonChannelModelSimulationCampaignUserUpdate, # pyright: ignore [reportInvalidTypeForm] +) -> IonChannelModelSimulationCampaignRead: + return router_update_one( + id_=id_, + db=db, + db_model_class=IonChannelModelSimulationCampaign, + user_context=user_context, + json_model=json_model, + response_schema_class=IonChannelModelSimulationCampaignRead, + apply_operations=_load, + ) + + +def admin_update_one( + db: SessionDep, + id_: uuid.UUID, + json_model: IonChannelModelSimulationCampaignAdminUpdate, # pyright: ignore [reportInvalidTypeForm] +) -> IonChannelModelSimulationCampaignRead: + return router_update_one( + id_=id_, + db=db, + db_model_class=IonChannelModelSimulationCampaign, + user_context=None, + json_model=json_model, + response_schema_class=IonChannelModelSimulationCampaignRead, + apply_operations=_load, + ) + + +def read_many( + user_context: UserContextDep, + db: SessionDep, + pagination_request: PaginationQuery, + filter_model: IonChannelModelSimulationCampaignFilterDep, + with_search: SearchDep, + facets: FacetsDep, +) -> ListResponse[IonChannelModelSimulationCampaignRead]: + agent_alias = aliased(Agent, flat=True) + created_by_alias = aliased(Person, flat=True) + updated_by_alias = aliased(Person, flat=True) + simulation_alias = aliased(Simulation, flat=True) + aliases: Aliases = { + Agent: { + "contribution": agent_alias, + }, + Person: { + "created_by": created_by_alias, + "updated_by": updated_by_alias, + }, + Simulation: simulation_alias, + } + facet_keys = filter_keys = [ + "created_by", + "updated_by", + "contribution", + "simulation", + ] + name_to_facet_query_params, filter_joins = query_params_factory( + db_model_class=IonChannelModelSimulationCampaign, + facet_keys=facet_keys, + filter_keys=filter_keys, + aliases=aliases, + ) + return router_read_many( + db=db, + filter_model=filter_model, + db_model_class=IonChannelModelSimulationCampaign, + with_search=with_search, + with_in_brain_region=None, + facets=facets, + name_to_facet_query_params=name_to_facet_query_params, + apply_filter_query_operations=None, + apply_data_query_operations=_load, + aliases=aliases, + pagination_request=pagination_request, + response_schema_class=IonChannelModelSimulationCampaignRead, + authorized_project_id=user_context.project_id, + filter_joins=filter_joins, + ) + + +def delete_one( + user_context: UserContextDep, + db: SessionDep, + id_: uuid.UUID, +) -> DeleteResponse: + return router_user_delete_one( + id_=id_, + db=db, + db_model_class=IonChannelModelSimulationCampaign, + user_context=user_context, + ) diff --git a/tests/utils.py b/tests/utils.py index f022964a..ef2f8832 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -33,6 +33,7 @@ ETypeClass, ETypeClassification, IonChannelModelingCampaign, + IonChannelModelSimulationCampaign, IonChannelRecording, MeasurementAnnotation, MTypeClass, @@ -95,6 +96,7 @@ ElectricalCellRecording: "/electrical-cell-recording", IonChannelRecording: "/ion-channel-recording", IonChannelModelingCampaign: "/ion-channel-modeling-campaign", + IonChannelModelSimulationCampaign: "/ion-channel-model-simulation-campaign", CircuitExtractionCampaign: "/circuit-extraction-campaign", SkeletonizationCampaign: "/skeletonization-campaign", SkeletonizationConfig: "/skeletonization-config", @@ -250,6 +252,27 @@ def create_ion_channel_modeling_campaign_id( return response.json()["id"] +def create_ion_channel_model_simulation_campaign_id( + client, + name="Test Ion Channel Model Simulation Campaign Name", + description="Test Ion Channel Model Simulation Campaign Description", + *, + authorized_public: bool = False, +): + response = client.post( + ROUTES[IonChannelModelSimulationCampaign], + json={ + "name": name, + "description": description, + "authorized_public": authorized_public, + "scan_parameters": {"foo": "bar"}, + }, + ) + + assert response.status_code == 200 + return response.json()["id"] + + def create_skeletonization_campaign_id( client, name="Test Skeletonization Campaign Name",