diff --git a/alembic/versions/20250521_131802_9730c55381f3_default_migration_message.py b/alembic/versions/20250521_131802_9730c55381f3_default_migration_message.py new file mode 100644 index 00000000..e4e7e896 --- /dev/null +++ b/alembic/versions/20250521_131802_9730c55381f3_default_migration_message.py @@ -0,0 +1,113 @@ +"""Default migration message + +Revision ID: 9730c55381f3 +Revises: b8490e92310f +Create Date: 2025-05-21 13:18:02.105223 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from alembic_postgresql_enum import TableReference + +from sqlalchemy import Text +import app.db.types + +# revision identifiers, used by Alembic. +revision: str = "9730c55381f3" +down_revision: Union[str, None] = "b8490e92310f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "validation_result", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("passed", sa.Boolean(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("validated_entity_id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(["id"], ["entity.id"], name=op.f("fk_validation_result_id_entity")), + sa.ForeignKeyConstraint( + ["validated_entity_id"], + ["entity.id"], + name=op.f("fk_validation_result_validated_entity_id_entity"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_validation_result")), + ) + op.create_index(op.f("ix_validation_result_name"), "validation_result", ["name"], unique=False) + op.create_index( + op.f("ix_validation_result_validated_entity_id"), + "validation_result", + ["validated_entity_id"], + unique=False, + ) + op.sync_enum_values( + enum_schema="public", + enum_name="entitytype", + new_values=[ + "analysis_software_source_code", + "brain_atlas", + "emodel", + "cell_composition", + "experimental_bouton_density", + "experimental_neuron_density", + "experimental_synapses_per_connection", + "memodel", + "mesh", + "me_type_density", + "reconstruction_morphology", + "electrical_cell_recording", + "electrical_recording_stimulus", + "single_neuron_simulation", + "single_neuron_synaptome", + "single_neuron_synaptome_simulation", + "ion_channel_model", + "subject", + "validation_result", + ], + affected_columns=[ + TableReference(table_schema="public", table_name="entity", column_name="type") + ], + enum_values_to_rename=[], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + enum_schema="public", + enum_name="entitytype", + new_values=[ + "analysis_software_source_code", + "brain_atlas", + "emodel", + "cell_composition", + "experimental_bouton_density", + "experimental_neuron_density", + "experimental_synapses_per_connection", + "memodel", + "mesh", + "me_type_density", + "reconstruction_morphology", + "electrical_cell_recording", + "electrical_recording_stimulus", + "single_neuron_simulation", + "single_neuron_synaptome", + "single_neuron_synaptome_simulation", + "ion_channel_model", + "subject", + ], + affected_columns=[ + TableReference(table_schema="public", table_name="entity", column_name="type") + ], + enum_values_to_rename=[], + ) + op.drop_index(op.f("ix_validation_result_validated_entity_id"), table_name="validation_result") + op.drop_index(op.f("ix_validation_result_name"), table_name="validation_result") + op.drop_table("validation_result") + # ### end Alembic commands ### diff --git a/app/db/model.py b/app/db/model.py index 8012e692..c62ba41a 100644 --- a/app/db/model.py +++ b/app/db/model.py @@ -821,6 +821,26 @@ class IonChannelModelToEModel(Base): ) +class ValidationResult(Entity): + __tablename__ = EntityType.validation_result.value + id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entity.id"), primary_key=True) + passed: Mapped[bool] = mapped_column(default=False) + + name: Mapped[str] = mapped_column(index=True) + + validated_entity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entity.id"), index=True) + validated_entity: Mapped[Entity] = relationship( + "Entity", + uselist=False, + foreign_keys=[validated_entity_id], + ) + + __mapper_args__ = { # noqa: RUF012 + "polymorphic_identity": __tablename__, + "inherit_condition": id == Entity.id, + } + + class Asset(Identifiable): """Asset table.""" diff --git a/app/db/types.py b/app/db/types.py index 64519a1e..3b4d2f22 100644 --- a/app/db/types.py +++ b/app/db/types.py @@ -66,6 +66,7 @@ class EntityType(StrEnum): single_neuron_synaptome_simulation = auto() ion_channel_model = auto() subject = auto() + validation_result = auto() class AgentType(StrEnum): diff --git a/app/filters/validation_result.py b/app/filters/validation_result.py new file mode 100644 index 00000000..6ebab395 --- /dev/null +++ b/app/filters/validation_result.py @@ -0,0 +1,25 @@ +import uuid +from typing import Annotated + +from fastapi_filter import FilterDepends + +from app.db.model import ValidationResult +from app.filters.base import CustomFilter +from app.filters.common import EntityFilterMixin + + +class ValidationResultFilter( + CustomFilter, + EntityFilterMixin, +): + passed: bool | None = None + validated_entity_id: uuid.UUID | None = None + + order_by: list[str] = ["name"] # noqa: RUF012 + + class Constants(CustomFilter.Constants): + model = ValidationResult + ordering_model_fields = ["name"] # noqa: RUF012 + + +ValidationResultFilterDep = Annotated[ValidationResultFilter, FilterDepends(ValidationResultFilter)] diff --git a/app/routers/__init__.py b/app/routers/__init__.py index a752d364..18740a08 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -31,6 +31,7 @@ species, strain, subject, + validation_result, ) router = APIRouter() @@ -47,6 +48,7 @@ experimental_bouton_density.router, experimental_neuron_density.router, experimental_synapses_per_connection.router, + ion_channel_model.router, license.router, measurement_annotation.router, memodel.router, @@ -61,7 +63,7 @@ species.router, strain.router, subject.router, - ion_channel_model.router, + validation_result.router, ] for r in authenticated_routers: router.include_router(r, dependencies=[Depends(user_verified)]) diff --git a/app/routers/validation_result.py b/app/routers/validation_result.py new file mode 100644 index 00000000..504f31fa --- /dev/null +++ b/app/routers/validation_result.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +import app.service.validation_result + +router = APIRouter( + prefix="/validation-result", + tags=["validation-result"], +) + +read_many = router.get("")(app.service.validation_result.read_many) +read_one = router.get("/{id_}")(app.service.validation_result.read_one) +create_one = router.post("")(app.service.validation_result.create_one) diff --git a/app/schemas/validation.py b/app/schemas/validation.py new file mode 100644 index 00000000..00702f0d --- /dev/null +++ b/app/schemas/validation.py @@ -0,0 +1,25 @@ +import uuid + +from pydantic import BaseModel + +from app.schemas.agent import CreatedByUpdatedByMixin +from app.schemas.base import ( + CreationMixin, + IdentifiableMixin, +) + + +class ValidationResultBase(BaseModel): + name: str + passed: bool + validated_entity_id: uuid.UUID + + +class ValidationResultRead( + ValidationResultBase, CreationMixin, IdentifiableMixin, CreatedByUpdatedByMixin +): + pass + + +class ValidationResultCreate(ValidationResultBase): + pass diff --git a/app/service/validation_result.py b/app/service/validation_result.py new file mode 100644 index 00000000..41e20715 --- /dev/null +++ b/app/service/validation_result.py @@ -0,0 +1,82 @@ +import uuid + +import sqlalchemy as sa +from sqlalchemy.orm import joinedload + +from app.db.model import Subject, ValidationResult +from app.dependencies.auth import UserContextDep, UserContextWithProjectIdDep +from app.dependencies.common import ( + FacetsDep, + InBrainRegionDep, + PaginationQuery, + SearchDep, +) +from app.dependencies.db import SessionDep +from app.filters.validation_result import ValidationResultFilterDep +from app.queries.common import router_create_one, router_read_many, router_read_one +from app.schemas.types import ListResponse +from app.schemas.validation import ValidationResultCreate, ValidationResultRead + + +def _load(query: sa.Select): + return query.options( + joinedload(Subject.species), + ) + + +def read_one( + user_context: UserContextDep, + db: SessionDep, + id_: uuid.UUID, +) -> ValidationResultRead: + return router_read_one( + db=db, + id_=id_, + db_model_class=ValidationResult, + authorized_project_id=user_context.project_id, + response_schema_class=ValidationResultRead, + apply_operations=_load, + ) + + +def create_one( + user_context: UserContextWithProjectIdDep, + json_model: ValidationResultCreate, + db: SessionDep, +) -> ValidationResultRead: + return router_create_one( + db=db, + user_context=user_context, + db_model_class=ValidationResult, + json_model=json_model, + response_schema_class=ValidationResultRead, + ) + + +def read_many( + user_context: UserContextDep, + db: SessionDep, + pagination_request: PaginationQuery, + filter_model: ValidationResultFilterDep, + with_search: SearchDep, + facets: FacetsDep, + in_brain_region: InBrainRegionDep, +) -> ListResponse[ValidationResultRead]: + aliases = {} + name_to_facet_query_params = {} + return router_read_many( + db=db, + filter_model=filter_model, + db_model_class=ValidationResult, + with_search=with_search, + with_in_brain_region=in_brain_region, + 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=ValidationResultRead, + authorized_project_id=user_context.project_id, + filter_joins=None, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 14c562f7..38173532 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -372,6 +372,20 @@ def mtype_class_id(db): ) +@pytest.fixture +def validation_result_id(client, morphology_id): + return assert_request( + client.post, + url="/validation-result", + json={ + "name": "test_validation_result", + "passed": True, + "validated_entity_id": str(morphology_id), + "authorized_public": False, + }, + ).json()["id"] + + CreateIds = Callable[[int], list[str]] diff --git a/tests/test_validation_result.py b/tests/test_validation_result.py new file mode 100644 index 00000000..4517f721 --- /dev/null +++ b/tests/test_validation_result.py @@ -0,0 +1,130 @@ +import uuid + +import pytest +from fastapi.testclient import TestClient + +from app.db.model import ValidationResult + +from .utils import assert_request + +MODEL = ValidationResult +ROUTE = "/validation-result" + + +@pytest.fixture +def json_data(morphology_id): + return { + "passed": True, + "validated_entity_id": str(morphology_id), + "name": "test_validation_result", + "authorized_public": False, + } + + +@pytest.fixture +def create(client, json_data): + def _create(**kwargs): + return assert_request(client.post, url=ROUTE, json=json_data | kwargs).json() + + return _create + + +def _assert_read_response(data, json_data): + assert data["name"] == json_data["name"] + assert data["passed"] == json_data["passed"] + assert data["validated_entity_id"] == json_data["validated_entity_id"] + assert data["createdBy"]["id"] == data["updatedBy"]["id"] + assert data["creation_date"] == data["update_date"] + + +def test_read_one(client: TestClient, validation_result_id, json_data): + data = assert_request(client.get, url=f"{ROUTE}/{validation_result_id}").json() + _assert_read_response(data, json_data) + assert data["id"] == validation_result_id + + +def test_create_one(client: TestClient, json_data): + data = assert_request(client.post, url=ROUTE, json=json_data).json() + _assert_read_response(data, json_data) + + data = assert_request(client.get, url=f"{ROUTE}/{data['id']}").json() + _assert_read_response(data, json_data) + + data = assert_request(client.get, url=ROUTE).json()["data"][0] + _assert_read_response(data, json_data) + + +def test_missing(client): + response = client.get(f"{ROUTE}/{uuid.uuid4()}") + assert response.status_code == 404 + + response = client.get(f"{ROUTE}/notauuid") + assert response.status_code == 422 + + +def test_filtering__one_entry(client, validation_result_id, morphology_id): + # no results expected for unrelated id + data = assert_request( + client.get, + url=ROUTE, + params={"validated_entity_id": str(validation_result_id)}, + ).json()["data"] + + assert len(data) == 0 + + data = assert_request( + client.get, + url=ROUTE, + params={"validated_entity_id": str(morphology_id)}, + ).json()["data"] + + assert len(data) == 1 + assert data[0]["validated_entity_id"] == str(morphology_id) + + +@pytest.fixture +def models(create, morphology_id, emodel_id): + return [ + create( + name="m1", + passed=True, + validated_entity_id=str(morphology_id), + ), + create( + name="m2", + passed=False, + validated_entity_id=str(morphology_id), + ), + create( + name="e1", + passed=True, + validated_entity_id=str(emodel_id), + ), + create( + name="e2", + passed=False, + validated_entity_id=str(emodel_id), + ), + ] + + +def test_filtering__many_entries(client, models, morphology_id, emodel_id): + data = assert_request( + client.get, + url=ROUTE, + params={"validated_entity_id": str(morphology_id)}, + ).json()["data"] + + assert len(data) == 2 + assert data[0]["validated_entity_id"] == models[0]["validated_entity_id"] + assert data[1]["validated_entity_id"] == models[1]["validated_entity_id"] + + data = assert_request( + client.get, + url=ROUTE, + params={"validated_entity_id": str(emodel_id)}, + ).json()["data"] + + assert len(data) == 2 + assert data[0]["validated_entity_id"] == models[2]["validated_entity_id"] + assert data[1]["validated_entity_id"] == models[3]["validated_entity_id"]