diff --git a/app/filters/circuit.py b/app/filters/circuit.py new file mode 100644 index 00000000..44eb1afc --- /dev/null +++ b/app/filters/circuit.py @@ -0,0 +1,56 @@ +import uuid +from datetime import datetime +from typing import Annotated + +from fastapi_filter import FilterDepends + +from app.db.model import Circuit +from app.filters.base import CustomFilter +from app.filters.common import ( + BrainRegionFilterMixin, + EntityFilterMixin, + NameFilterMixin, + SubjectFilterMixin, +) + + +class ScientificArtifactFilter( + CustomFilter, SubjectFilterMixin, BrainRegionFilterMixin, EntityFilterMixin +): + experiment_date__lte: datetime | None = None + experiment_date__gte: datetime | None = None + contact_id: uuid.UUID | None = None + + +class CircuitFilter(ScientificArtifactFilter, NameFilterMixin): + atlas_id: uuid.UUID | None = None + root_circuit_id: uuid.UUID | None = None + + has_morphologies: bool | None = None + has_point_neurons: bool | None = None + has_electrical_cell_models: bool | None = None + has_spines: bool | None = None + + number_neurons__lte: int | None = None + number_neurons__gte: int | None = None + + number_synapses__lte: int | None = None + number_synapses__gte: int | None = None + + number_connections__lte: int | None = None + number_connections__gte: int | None = None + + build_category: str | None = None + build_category__in: list[str] | None = None + + scale: str | None = None + scale__in: list[str] | None = None + + order_by: list[str] = ["-creation_date"] # noqa: RUF012 + + class Constants(CustomFilter.Constants): + model = Circuit + ordering_model_fields = ["creation_date", "update_date", "name"] # noqa: RUF012 + + +CircuitFilterDep = Annotated[CircuitFilter, FilterDepends(CircuitFilter)] diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 4a17bf63..ac47c4b0 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -9,6 +9,7 @@ brain_region, brain_region_hierarchy, cell_composition, + circuit, contribution, derivation, electrical_cell_recording, @@ -45,6 +46,7 @@ brain_region.router, brain_region_hierarchy.router, cell_composition.router, + circuit.router, contribution.router, derivation.router, electrical_cell_recording.router, diff --git a/app/routers/circuit.py b/app/routers/circuit.py new file mode 100644 index 00000000..3c65e418 --- /dev/null +++ b/app/routers/circuit.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +import app.service.circuit + +router = APIRouter( + prefix="/circuit", + tags=["circuit"], +) + +read_many = router.get("")(app.service.circuit.read_many) +read_one = router.get("/{id_}")(app.service.circuit.read_one) +create_one = router.post("")(app.service.circuit.create_one) diff --git a/app/schemas/base.py b/app/schemas/base.py index 7d5813c2..58f65403 100644 --- a/app/schemas/base.py +++ b/app/schemas/base.py @@ -59,6 +59,14 @@ class LicenseRead(LicenseCreate, CreationMixin, IdentifiableMixin): pass +class LicenseReadMixin: + license: LicenseRead | None = None + + +class LicenseCreateMixin: + license_id: uuid.UUID | None = None + + class BrainRegionRead(IdentifiableMixin, CreationMixin): model_config = ConfigDict(from_attributes=True) diff --git a/app/schemas/circuit.py b/app/schemas/circuit.py new file mode 100644 index 00000000..5a17b109 --- /dev/null +++ b/app/schemas/circuit.py @@ -0,0 +1,36 @@ +import uuid + +from pydantic import BaseModel, ConfigDict + +from app.db.types import CircuitBuildCategory, CircuitScale +from app.schemas.scientific_artifact import ScientificArtifactCreate, ScientificArtifactRead + + +class CircuitBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + name: str + description: str + + has_morphologies: bool = False + has_point_neurons: bool = False + has_electrical_cell_models: bool = False + has_spines: bool = False + + number_neurons: int + number_synapses: int + number_connections: int | None + + scale: CircuitScale + build_category: CircuitBuildCategory + + root_circuit_id: uuid.UUID | None = None + atlas_id: uuid.UUID | None = None + + +class CircuitRead(CircuitBase, ScientificArtifactRead): + pass + + +class CircuitCreate(CircuitBase, ScientificArtifactCreate): + pass diff --git a/app/schemas/scientific_artifact.py b/app/schemas/scientific_artifact.py new file mode 100644 index 00000000..798e0b60 --- /dev/null +++ b/app/schemas/scientific_artifact.py @@ -0,0 +1,52 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.schemas.agent import CreatedByUpdatedByMixin +from app.schemas.asset import AssetsMixin +from app.schemas.base import ( + AuthorizationMixin, + AuthorizationOptionalPublicMixin, + BrainRegionCreateMixin, + BrainRegionReadMixin, + CreationMixin, + EntityTypeMixin, + IdentifiableMixin, + LicenseCreateMixin, + LicenseReadMixin, +) +from app.schemas.subject import SubjectCreateMixin, SubjectReadMixin + + +class ScientificArtifactBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + experiment_date: datetime | None = None + contact_id: uuid.UUID | None = None + atlas_id: uuid.UUID | None = None + + +class ScientificArtifactRead( + ScientificArtifactBase, + SubjectReadMixin, + BrainRegionReadMixin, + CreatedByUpdatedByMixin, + CreationMixin, + LicenseReadMixin, + EntityTypeMixin, + IdentifiableMixin, + AuthorizationMixin, + AssetsMixin, +): + pass + + +class ScientificArtifactCreate( + ScientificArtifactBase, + SubjectCreateMixin, + BrainRegionCreateMixin, + LicenseCreateMixin, + AuthorizationOptionalPublicMixin, +): + pass diff --git a/app/schemas/subject.py b/app/schemas/subject.py index f95af824..5c6b42d3 100644 --- a/app/schemas/subject.py +++ b/app/schemas/subject.py @@ -90,3 +90,7 @@ class SubjectRead( class SubjectReadMixin: subject: NestedSubjectRead + + +class SubjectCreateMixin: + subject_id: uuid.UUID diff --git a/app/service/circuit.py b/app/service/circuit.py new file mode 100644 index 00000000..02f43490 --- /dev/null +++ b/app/service/circuit.py @@ -0,0 +1,122 @@ +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, + Circuit, + Subject, +) +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.circuit import CircuitFilterDep +from app.queries.common import router_create_one, router_read_many, router_read_one +from app.queries.factory import query_params_factory +from app.schemas.circuit import ( + CircuitCreate, + CircuitRead, +) +from app.schemas.types import ListResponse + +if TYPE_CHECKING: + from app.filters.base import Aliases + + +def _load(query: sa.Select): + return query.options( + joinedload(Circuit.license), + joinedload(Circuit.subject).joinedload(Subject.species), + joinedload(Circuit.brain_region), + joinedload(Circuit.created_by), + joinedload(Circuit.updated_by), + selectinload(Circuit.assets), + raiseload("*"), + ) + + +def read_one( + user_context: UserContextDep, + db: SessionDep, + id_: uuid.UUID, +) -> CircuitRead: + return router_read_one( + db=db, + id_=id_, + db_model_class=Circuit, + authorized_project_id=user_context.project_id, + response_schema_class=CircuitRead, + apply_operations=_load, + ) + + +def create_one( + db: SessionDep, + json_model: CircuitCreate, + user_context: UserContextWithProjectIdDep, +) -> CircuitRead: + return router_create_one( + db=db, + json_model=json_model, + user_context=user_context, + db_model_class=Circuit, + response_schema_class=CircuitRead, + apply_operations=_load, + ) + + +def read_many( + user_context: UserContextDep, + db: SessionDep, + pagination_request: PaginationQuery, + filter_model: CircuitFilterDep, + with_search: SearchDep, + facets: FacetsDep, + in_brain_region: InBrainRegionDep, +) -> ListResponse[CircuitRead]: + agent_alias = aliased(Agent, flat=True) + created_by_alias = aliased(Agent, flat=True) + updated_by_alias = aliased(Agent, flat=True) + + aliases: Aliases = { + Agent: { + "contribution": agent_alias, + "created_by": created_by_alias, + "updated_by": updated_by_alias, + } + } + facet_keys = filter_keys = [ + "brain_region", + "created_by", + "updated_by", + "contribution", + ] + name_to_facet_query_params, filter_joins = query_params_factory( + db_model_class=Circuit, + facet_keys=facet_keys, + filter_keys=filter_keys, + aliases=aliases, + ) + return router_read_many( + db=db, + filter_model=filter_model, + db_model_class=Circuit, + 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=CircuitRead, + authorized_project_id=user_context.project_id, + filter_joins=filter_joins, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 70a3a0e8..280a0917 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,8 @@ from app.db.model import ( Agent, Base, + BrainAtlas, + Circuit, Contribution, EModel, ETypeClass, @@ -363,6 +365,23 @@ def brain_region_id(db, brain_region_hierarchy_id, person_id): ) +@pytest.fixture +def brain_atlas_id(db, brain_region_hierarchy_id, person_id, species_id): + return add_db( + db, + BrainAtlas( + name="test brain atlas", + description="test brain atlas description", + species_id=species_id, + hierarchy_id=brain_region_hierarchy_id, + authorized_project_id=PROJECT_ID, + authorized_public=False, + created_by_id=person_id, + updated_by_id=person_id, + ), + ).id + + @pytest.fixture def morphology_id(db, client, species_id, strain_id, brain_region_id, person_id): model_id = utils.create_reconstruction_morphology_id( @@ -851,3 +870,81 @@ def trace_id_with_assets(db, client, tmp_path, electrical_cell_recording_json_da return create_electrical_cell_recording_id_with_assets( db, client, tmp_path, electrical_cell_recording_json_data ) + + +@pytest.fixture +def root_circuit_json_data(brain_atlas_id, subject_id, brain_region_id, license_id): + return { + "name": "root-circuit", + "description": "root-circuit-description", + "number_neurons": 10_000_000, + "number_synapses": 1_000_000_000, + "number_connections": 100_000_000, + "has_morphologies": True, + "has_point_neurons": True, + "has_spines": True, + "has_electrical_cell_models": True, + "scale": "whole_brain", + "root_circuit_id": None, + "atlas_id": str(brain_atlas_id), + "subject_id": str(subject_id), + "build_category": "em_reconstruction", + "authorized_project_id": PROJECT_ID, + "authorized_public": False, + "created_by_id": str(person_id), + "updated_by_id": str(person_id), + "brain_region_id": str(brain_region_id), + "license_id": str(license_id), + } + + +@pytest.fixture +def root_circuit(db, root_circuit_json_data, person_id): + return add_db( + db, + Circuit( + **root_circuit_json_data + | { + "created_by_id": person_id, + "updated_by_id": person_id, + "authorized_project_id": PROJECT_ID, + } + ), + ) + + +@pytest.fixture +def circuit_json_data(brain_atlas_id, root_circuit, subject_id, brain_region_id, license_id): + return { + "name": "my-circuit", + "description": "My Circuit", + "has_morphologies": True, + "has_point_neurons": False, + "has_electrical_cell_models": True, + "has_spines": False, + "number_neurons": 5, + "number_synapses": 100, + "number_connections": 10, + "scale": "microcircuit", + "build_category": "computational_model", + "atlas_id": str(brain_atlas_id), + "root_circuit_id": str(root_circuit.id), + "subject_id": str(subject_id), + "brain_region_id": str(brain_region_id), + "license_id": str(license_id), + } + + +@pytest.fixture +def circuit(db, circuit_json_data, person_id): + return add_db( + db, + Circuit( + **circuit_json_data + | { + "created_by_id": person_id, + "updated_by_id": person_id, + "authorized_project_id": PROJECT_ID, + } + ), + ) diff --git a/tests/test_circuit.py b/tests/test_circuit.py new file mode 100644 index 00000000..bf22a74b --- /dev/null +++ b/tests/test_circuit.py @@ -0,0 +1,186 @@ +import pytest + +from app.db.model import Circuit +from app.db.types import CircuitBuildCategory, CircuitScale, EntityType + +from .utils import ( + PROJECT_ID, + add_all_db, + assert_request, + check_authorization, + check_creation_fields, + check_missing, + check_pagination, +) + +ROUTE = "circuit" + + +@pytest.fixture +def create_id(client, root_circuit_json_data): + def _create_id(**kwargs): + return assert_request(client.post, url=ROUTE, json=root_circuit_json_data | kwargs).json()[ + "id" + ] + + return _create_id + + +def _assert_read_response(data, json_data): + assert "id" in data + assert "authorized_public" in data + assert "authorized_project_id" in data + assert "assets" in data + assert data["name"] == json_data["name"] + assert data["description"] == json_data["description"] + assert data["subject"]["id"] == json_data["subject_id"] + assert data["license"]["id"] == json_data["license_id"] + assert data["root_circuit_id"] == json_data["root_circuit_id"] + assert data["atlas_id"] == json_data["atlas_id"] + assert data["type"] == EntityType.circuit + assert data["has_morphologies"] == json_data["has_morphologies"] + assert data["has_point_neurons"] == json_data["has_point_neurons"] + assert data["has_electrical_cell_models"] == json_data["has_electrical_cell_models"] + assert data["has_spines"] == json_data["has_spines"] + assert data["build_category"] == json_data["build_category"] + assert data["scale"] == json_data["scale"] + + check_creation_fields(data) + + +def test_create_one(client, circuit_json_data): + data = assert_request(client.post, url=ROUTE, json=circuit_json_data).json() + _assert_read_response(data, circuit_json_data) + + +def test_read_one(client, circuit, circuit_json_data): + data = assert_request(client.get, url=f"{ROUTE}/{circuit.id}").json() + _assert_read_response(data, circuit_json_data) + + +def test_read_many(client, circuit, circuit_json_data): + data = assert_request(client.get, url=f"{ROUTE}").json()["data"] + + # circuit and root circuit + assert len(data) == 2 + + circuit_data = next(d for d in data if d["id"] == str(circuit.id)) + _assert_read_response(circuit_data, circuit_json_data) + + +def test_missing(client): + check_missing(ROUTE, client) + + +def test_authorization(client_user_1, client_user_2, client_no_project, root_circuit_json_data): + # using root_circuit_json_data to avoid the implication of creating two circuits + # because of the root_circuit_id in circuit_json_data which messes up the check assumptions + check_authorization( + ROUTE, client_user_1, client_user_2, client_no_project, root_circuit_json_data + ) + + +def test_pagination(client, create_id): + check_pagination(ROUTE, client, create_id) + + +@pytest.fixture +def models(db, circuit_json_data, person_id): + booleans = [True, False, True, False, True, False] + + scales = [ + CircuitScale.single, + CircuitScale.microcircuit, + CircuitScale.whole_brain, + CircuitScale.single, + CircuitScale.microcircuit, + CircuitScale.whole_brain, + ] + categories = [ + CircuitBuildCategory.computational_model, + CircuitBuildCategory.em_reconstruction, + CircuitBuildCategory.computational_model, + CircuitBuildCategory.em_reconstruction, + CircuitBuildCategory.computational_model, + CircuitBuildCategory.em_reconstruction, + ] + + db_circuits = [ + Circuit( + **( + circuit_json_data + | { + "name": f"circuit-{i}", + "description": f"circuit-description-{i}", + "has_morphologies": bool_value, + "has_point_neurons": bool_value, + "has_electrical_cell_models": bool_value, + "has_spines": bool_value, + "number_neurons": 10 * i + 1, + "number_synapses": 1000 * i + 1, + "number_connections": 100 * i + 1, + "scale": scale, + "build_category": category, + "created_by_id": person_id, + "updated_by_id": person_id, + "authorized_project_id": PROJECT_ID, + } + ) + ) + for i, (bool_value, scale, category) in enumerate( + zip(booleans, scales, categories, strict=False) + ) + ] + + add_all_db(db, db_circuits) + + return db_circuits + + +def test_filtering(client, root_circuit, models): + data = assert_request( + client.get, url=ROUTE, params={"root_circuit_id": str(root_circuit.id)} + ).json()["data"] + assert len(data) == len(models) + + data = assert_request( + client.get, + url=ROUTE, + params={ + "root_circuit_id": str(root_circuit.id), + "has_morphologies": True, + }, + ).json()["data"] + assert len(data) == 3 + + data = assert_request( + client.get, + url=ROUTE, + params={ + "root_circuit_id": str(root_circuit.id), + "has_morphologies": True, + "has_point_neurons": False, + }, + ).json()["data"] + assert len(data) == 0 + + data = assert_request( + client.get, + url=ROUTE, + params={ + "root_circuit_id": str(root_circuit.id), + "build_category": "computational_model", + "number_neurons__lte": 11, + }, + ).json()["data"] + assert len(data) == 1 + + data = assert_request( + client.get, + url=ROUTE, + params={ + "root_circuit_id": str(root_circuit.id), + "scale__in": "single,whole_brain", + }, + ).json()["data"] + assert len(data) == 4